50 Commits
Author SHA1 Message Date
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
Puranjay Savar Mattas b28dc019aa Merge pull request 'Document sharing: fix decode failures, replace broken Published toggle with real permissions' (#5) from fix/document-sharing into main
Reviewed-on: #5
2026-08-14 22:13:02 +01:00
Puranjay Savar Mattas a870e99404 chore: bump version to 0.0.2 2026-08-14 22:08:19 +01:00
Puranjay Savar Mattas 9ca1c77bb9 refactor(share): popover instead of modal sheet, redesigned layout
Share button now opens a popover anchored to the toolbar icon instead
of a full modal window. Redesigned the content as a narrow vertical
card (icon section headers, avatar-initial rows for members, link
card with collapsible title override) sized to fit the People section
without scrolling in the common case.
2026-08-14 22:06:04 +01:00
Puranjay Savar Mattas 7216633299 feat(share): drop broken Published toggle, add real document permissions
Flipping "Published" 403'd with authorization_error, confirmed via a
raw curl (bypassing our client entirely, real token) to be a genuine
server-side restriction independent of this app — workspace public
sharing is enabled, token is full-scope, it's not document-specific.
Root cause is most likely Outline gating that action behind an
interactive session rather than API-token auth, but that's not
definitively confirmed server-side. Removed the toggle per explicit
instruction; kept link create/copy/revoke and title override (same
endpoint, not reported broken).

Replaced it with real per-document user permissions:
- OutlineMembership/OutlineDocumentMember models
- documents.add_user (confirmed shape from official docs),
  documents.remove_user/documents.users (speculative, same "best-effort
  until a live server confirms" treatment OutlinePin originally got),
  users.list for the invite search (standard, high-confidence)
- DocumentShareSheet gets a "People with access" section: search and
  invite with a Can-view/Can-edit picker, existing members listed with
  a remove button
- Reader toolbar's long-disabled "Permissions…" menu item now opens
  this same sheet instead of doing nothing

Sidebar's own disabled "Permissions…" stub has no sheet wired up to it
yet — comment updated to be accurate, not fixed (use the reader's menu
instead). documents.users/documents.remove_user are unverified against
a live server, same as every other speculative endpoint this session —
expect a correction round once tested.
2026-08-14 20:18:03 +01:00
Puranjay Savar Mattas 013df89b4f fix(shares): shares.info wraps the share in {shares: [...]}, not bare
Empty-body handling fixed "no share yet" but re-opening the share
sheet for a document that already had one still errored — confirmed
via a raw response capture that data is { shares: [...] }, a
one-element array, not the bare share object the docs show. Same
pattern as pins.list.

Added SharesInfoPayload (mirrors PinsListPayload), shareInfo now takes
.shares.first. createShare/updateShare are unaffected — the user's
earlier successful create-and-copy confirms those aren't wrapped this
way, only shares.info. Test rewritten with the exact captured payload.
2026-08-14 17:55:07 +01:00
Puranjay Savar Mattas 1476c6c6ba fix(shares): treat shares.info's empty-body response as no-share, not an error
The full-shape fix wasn't the actual bug — confirmed live that
shares.info returns HTTP 200 with a completely empty body (not a 404,
not {data: null}) when no share exists yet for a document. post(_:)
assumed any 2xx had a non-empty envelope to decode, so this hit
JSONDecoder with zero bytes and threw "not valid JSON" on every first
share-sheet open.

Added postOptional(_:body:), mirroring post/postForSuccess, that
treats an empty body the same as a 404: nil, not a decode failure.
shareInfo calls it directly instead of wrapping post() with a
notFound-only catch.
2026-08-14 17:49:37 +01:00
Puranjay Savar Mattas 43f9d6052f fix(shares): match documented API shape, fill in list/revoke, harden share sheet
Share sheet errored "Got an unexpected response from the server" (a
client-side decode failure) on every open. OutlineShare only declared
5 fields against the real response's ~20, with url non-optional —
something in a real payload came back null and blew up the strict
decode, same class of bug as OutlinePin's history: self-hosted
responses keep diverging from what the hosted-app docs imply is
non-nullable.

- Rewrote OutlineShare to match the full documented shares.* response
  shape. Only id/published are trusted non-optional; everything else
  (documentTitle, sourceTitle, urlId, domain, title, iconUrl,
  includeChildDocuments, allowSubscriptions, allowIndexing,
  showLastUpdated, showTOC, views, createdBy, createdAt, updatedAt,
  lastAccessedAt) is optional so an unexpectedly-null field can't crash
  the decode again.
- shares.list and shares.revoke were never wrapped at all — added both.
- UpdateShareRequest was missing the documented title/iconUrl overrides.
- DocumentShareSheet: handles share.url being optional, adds a
  public-page title override field, adds a Revoke Link button
  (confirmation dialog, resets back to "Create Share Link" after).
- Removed dead DocumentReaderViewModel.share/loadShare()/
  createOrLoadShare() — never called by anything; DocumentShareSheet
  manages its own share state independently.
- Added a decode test using the exact payload from Outline's official
  shares.info docs, plus tests for shares.list and shares.revoke.

Branched fresh off main (post home-page merge) rather than continuing
on feature/home-page, to keep this its own PR.
2026-08-14 17:41:28 +01:00
Puranjay Savar Mattas db59e3fbe5 Merge pull request 'Add Home page, universal New Document dialog, and fix Pin/toggle/CODEOWNERS gaps found in live testing' (#4) from feature/home-page into main
Reviewed-on: #4
2026-08-14 17:23:26 +01:00
Puranjay Savar Mattas 8ce0804c67 fix(home): remove dead search icon, add staleness polling and retry
Three parity gaps found reviewing Home against CollectionOverviewView:

- The toolbar's contextual search icon rendered on Home but
  contextualSearchQuery is only ever read by CollectionOverviewView —
  clicking it and typing did nothing. Hidden specifically on the Home
  landing page per explicit request (sidebar's global search already
  covers this); goHome() also clears the stale query/expanded state so
  it can't leak back in when returning to a collection.
- Home had no periodic remote-changes check, unlike CollectionsTreeView
  and CollectionOverviewView. Added the same 45s-poll + banner pattern,
  fingerprinting pinned docs and the current tab's docs against fresh
  fetches; bails silently on a fetch failure instead of false-positive
  triggering the banner.
- Tab load errors showed a message but no way to retry short of
  switching tabs and back. Added a Retry button matching the collection
  document list's.

Also hardens CODEOWNERS ahead of going public: kept `* @psmattas` as
the catch-all but pinned supply-chain/governance/CI paths (Package
manifests, .gitea/, xcodeproj build settings, scripts/, LICENSE,
CONTRIBUTING/SECURITY/SETUP, CLAUDE.md, docs/ARCHITECTURE.md)
explicitly to @psmattas so they stay owner-gated even if `*` opens up
to other contributors later.
2026-08-14 17:19:07 +01:00
Puranjay Savar Mattas 9b429b2e00 Merge branch 'main' into feature/home-page
# Conflicts:
#	Outpost/Features/Collections/CollectionDocumentsOutline.swift
#	Outpost/Features/Collections/CollectionsTreeView.swift
#	Outpost/Features/Collections/ContentView_macOS.swift
#	Outpost/Features/Collections/DocumentReaderView.swift
2026-08-14 17:10:12 +01:00
Puranjay Savar Mattas 1b6aede326 Merge pull request 'Fix Pin, Subscribe, sidebar toggles, and stale-sidebar bugs found in live testing' (#3) from chore/verify-pins-subscriptions-api into main
Reviewed-on: #3
2026-08-14 17:02:31 +01:00
Puranjay Savar Mattas 1096967645 feat(sidebar): add a Home row above the collections list
Home was only reachable via the toolbar house icon — added a pinned
row at the top of the sidebar (matching a collection row's style,
highlighted when active) so it's a first-class nav target alongside
collections rather than toolbar-only.
2026-08-14 16:52:34 +01:00
Puranjay Savar Mattas ed9fd26c85 fix(sidebar): auto-refresh after creating a doc from the reader toolbar
The reader's inline "New Document" button (createChildDocument())
only navigated to the new document — no handle on the sidebar row it
landed under, so the tree stayed stale until the periodic
remote-changes poll surfaced the reload banner.

Right-click "New Document" on a collection already refreshed correctly
(bumps its own documentsRefreshToken) and is untouched.

Threads a documentsChangedToken from ContentView_macOS down through
CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline
as externalRefreshToken; every expanded row reloads itself when it
bumps, since the reader doesn't know which row (if any) corresponds to
where the new document landed.
2026-08-14 16:50:09 +01:00
Puranjay Savar Mattas c2b41ca960 fix(sidebar): auto-refresh after creating a doc from Home or the reader toolbar
The reader's inline "New Document" button and Home's New Document sheet
only navigated to the new document — neither had a handle on the
sidebar row it landed under, so the tree stayed stale until the
periodic remote-changes poll (45s) surfaced the "reload" banner.

New Document flows that already have a direct handle on their own
sidebar row (right-click a collection, right-click a document) already
refreshed correctly and are untouched.

Threads a documentsChangedToken from ContentView_macOS down through
CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline
as externalRefreshToken; every expanded row reloads itself when it
bumps, since neither Home nor the reader knows which row (if any)
corresponds to where the new document landed.
2026-08-14 16:46:40 +01:00
Puranjay Savar Mattas b45d72238c fix(reader): real toggle checkmarks for Subscribed/Viewer Insights/Full Width
This branch never got the toggle-menu fixes that landed on
chore/verify-pins-subscriptions-api — Subscribed/Viewer Insights/Full
Width were plain Buttons with no on/off indicator, and the menu didn't
force a rebuild on state change, so even a real Toggle would've shown a
stale checkmark until the view was torn down and rebuilt.

Ported that branch's fixes: Subscribed/Viewer Insights/Full Width are
now real Toggle views bound to observable state; the overflow Menu is
keyed to a .id() built from every toggle-backed state so SwiftUI
actually re-evaluates the checkmarks; viewer avatars are gated on
isInsightsEnabled so they hide immediately when insights are turned
off. Also dropped the dead documentEmbeds field (confirmed no
per-document embeds endpoint exists) — Enable Embeds is a disabled
button with an explanation, matching the other branch.
2026-08-14 16:41:20 +01:00
Puranjay Savar Mattas ad3e35e28e fix(pins): decode pins.list correctly, distinguish Pin to Home vs Collection
Same root-cause fix as feature/home-page, applied to this branch's
copy of the pin code (which additionally has the sidebar's real
per-collection Pin wiring):

- pins.list's real response is {data: {pins: [...], documents: [...]}},
  not a bare array — confirmed against a live server. Decoding straight
  to [OutlinePin] threw every call; try? swallowed it, so pins never
  showed up (even a doc pinned for real via the web app).
- "Pin to Home" (web's actual label) sends collectionId: null; "Pin to
  Collection" sends a real id — distinct actions. The reader toolbar's
  Pin was sending the doc's own collectionId under a plain "Pin" label,
  silently doing the wrong one. Fixed to nil, relabeled "Pin to Home".
- Sidebar's per-document Pin was already correctly scoped to
  collection.id — relabeled "Pin to Collection" for clarity, no logic
  change.
2026-08-14 16:36:07 +01:00
Puranjay Savar Mattas c1810f38f1 fix(pins): decode pins.list correctly, use collectionId:nil for Pin to Home
pins.list's real response is {data: {pins: [...], documents: [...]}},
not a bare array — confirmed against a live server's network traffic.
Decoding straight to [OutlinePin] threw on every call, and call sites
swallow that with try?, so pins never showed up anywhere (including
docs already pinned via the real web app).

Also: the reader's Pin action was sending the document's own
collectionId, which is "Pin to Collection" — a different action from
"Pin to Home" (collectionId: null), which is what the web app's "Pin
to Home" menu item actually does and what the Home page's pinned
section filters for.
2026-08-14 16:32:59 +01:00
Puranjay Savar Mattas 23193034e6 fix(home): distinct pinned card style, matching tab bar, drop broken sort
- Pinned section uses a new dense PinnedDocumentCard (single-line,
  explicit pin glyph) instead of the same tall card as the tab grids -
  it's meant for a quick scan of a small curated set, not browsing,
  and needed to actually show a pin so pinned docs are recognizable
  at a glance.
- Tab bar now matches CollectionOverviewView.tabBar's exact style
  instead of the native segmented picker.
- Popular tab's sort: "viewCount" was a guess and the server rejected
  it outright ("sort: Invalid input") - sort is validated against a
  fixed set server-side, not free-form like the vendored spec's typing
  implies. Falls back to default order now, same conclusion already
  reached for CollectionTab.popular - no real popularity ranking is
  exposed via the REST API.
- Layout: pinned section now claims roughly the top half of the page
  (scrolling within itself if there are more pinned docs than fit)
  when there's anything pinned, collapsing away entirely otherwise so
  the tabs get full height.
2026-08-14 16:16:27 +01:00
Puranjay Savar Mattas 278e93ddeb fix(reader): tie viewer avatars to the Viewer Insights toggle
Turning off Viewer Insights didn't hide the avatar stack until the
view was torn down and rebuilt - that data belongs to the insights
feature, so gate it on isInsightsEnabled directly instead of only on
whether any viewers loaded.
2026-08-14 16:14:33 +01:00
Puranjay Savar Mattas 02023cf382 fix(reader): force menu rebuild so toggle checkmarks reflect state
Viewer Insights (and likely Subscribed/Full Width, same mechanism)
kept showing the pre-toggle checkmark in the overflow menu until the
whole view was torn down and rebuilt (navigate away and back) -
SwiftUI's macOS Menu doesn't reliably re-evaluate a Toggle's checkmark
against updated @Observable state on its own. Keying the Menu's .id()
to every toggle-backed state it displays forces a fresh rebuild
whenever any of them change.
2026-08-14 16:08:44 +01:00
Puranjay Savar Mattas c3083bf0c0 feat: Home page + universal New Document dialog
Home replaces "auto-select first collection" as the landing state -
new toolbar Home button, Home pill in the breadcrumb, and the sidebar
no longer picks a collection for you on launch.

Home page:
- Pinned docs as a card grid (pins.list -> per-document fetch, since
  the speculative pins.list response only carries pin records, not
  documents - N+1 is acceptable here since pins are a small curated
  set, unlike a full collection tree)
- Recently Viewed (documents.viewed), Recently Updated and Created by
  Me (documents.list with sort/direction/userId - new richer
  DocumentsListRequest alongside the existing simple listDocuments,
  left untouched for its callers), and Popular (best-effort sort:
  "viewCount" - no confirmed popularity key in the vendored spec,
  worth eyeballing against a real server)
- New Document button in Home's own toolbar

New Document is now one shared dialog (NewDocumentSheet, mirrors
MoveDocumentSheet's collection+parent picker) instead of three
separate call sites that silently created "Untitled" instantly:
sidebar collection's New Document, sidebar document's New Document,
and the reader's toolbar button + menu item all open it now,
pre-filled with whatever context they were opened from.

OutlineKit: documents.viewed, richer documents.list filtering, with
test coverage.
2026-08-14 16:03:17 +01:00
Puranjay Savar Mattas fa31cd707e fix(collections): wire up sidebar Pin, drop dead Embeds field, real toggles
Smoke-tested against a live server: Pin didn't work from the sidebar
context menu, Subscribe/Unsubscribe worked, Enable Embeds didn't.

- Sidebar document context menu still had the pre-pins.*-support
  disabled Pin/Unsubscribe stubs from before that endpoint existed -
  only the reader's menu got updated at the time. Wired up real Pin
  (collection-scoped pins.list loaded once per CollectionDocumentsOutline,
  not per row - avoids an N+1 call storm); left Unsubscribe disabled
  there since subscriptions.list is per-document, with an accurate
  comment pointing at the reader's menu instead.
- documentEmbeds confirmed not a real field - removed from
  UpdateDocumentRequest entirely rather than leave a menu item that
  silently no-ops.
- Subscribe, Viewer Insights, and Full Width are now real Toggle menu
  items (checkmark reflects actual state) instead of static
  action buttons. Viewer Insights state is inferred from whether
  documents.insights succeeds/fails, since insightsEnabled isn't
  readable back off Document - heuristic, flagged in code.
2026-08-14 15:43:14 +01:00
Puranjay Savar Mattas 9991302683 Merge pull request 'Add feature/question issue templates, ignore local checklist' (#2) from feature/macos-app into main
Reviewed-on: #2
2026-08-14 15:21:43 +01:00
Puranjay Savar Mattas d5183a1300 chore: ignore local working checklist
TODO.local.md is a scratch tracker for in-progress work, kept
deliberately out of version control - decisions worth keeping belong
in CLAUDE.md/docs/ARCHITECTURE.md instead.
2026-08-14 15:18:31 +01:00
Puranjay Savar Mattas b1e8eb958e docs: add feature request and question issue templates
Rounds out the issue template set (bug/docs/security already existed)
- feature requests point at CLAUDE.md's phased build order since most
useful requests here are "match what web Outline does", and questions
point at SETUP.md first since blank issues are disabled.
2026-08-14 15:12:18 +01:00
Puranjay Savar Mattas 8c35710521 docs: add community health files and Gitea issue/PR templates
CODEOWNERS, CONTRIBUTING.md, SECURITY.md, SETUP.md, and the .gitea
issue/PR templates, rewritten for Outpost (they started as copies from
an unrelated project's templates - stripped the cross-repo/ticket-ID
conventions and the entirely different tech stack in SETUP.md,
replaced with this repo's actual submodule/OutlineKit/Xcode workflow).
2026-08-14 04:06:46 +01:00
Puranjay Savar Mattas 3e7d8097ad fix(scripts): correctly detect an annotated tag at HEAD in changelog range
git rev-parse on an annotated tag (git tag -a, what the release process
uses) returns the tag object's hash, not the commit hash it points to
- so the HEAD == tag comparison never matched, and the script always
fell into the "not tagged yet" branch. Fixed by peeling the tag down
to its commit with ^{commit}. Verified against a real annotated tag:
now correctly shows "Changelog (<tag>)" with actual content instead
of an empty "since <tag>" section.

Also: untrack Outpost.xcodeproj/xcuserdata (already gitignored, but
was committed before that rule existed, so Xcode kept dirtying it and
blocking pulls - including the one that just happened) and ignore
scripts/package-dmg.sh's dist/ output.
2026-08-14 03:44:47 +01:00
68 changed files with 4511 additions and 292 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Bug Report
about: Report a bug in Outpost
labels:
- "type: bug"
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.
- type: input
id: summary
attributes:
label: Summary
placeholder: Brief description of the bug
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
value: |
1.
2.
3.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: Logs, screenshots, macOS version, Outline server version, etc.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+30
View File
@@ -0,0 +1,30 @@
name: Documentation
about: Report missing, incorrect, or outdated documentation
labels:
- "type: docs"
body:
- type: input
id: page
attributes:
label: Affected Page / File
placeholder: "e.g. SETUP.md, docs/ARCHITECTURE.md, CLAUDE.md"
validations:
required: true
- type: dropdown
id: type
attributes:
label: Type
options:
- Missing documentation
- Incorrect / outdated information
- Unclear or confusing
- Typo / formatting
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: What needs to change and why?
validations:
required: true
@@ -0,0 +1,38 @@
name: Feature Request
about: Suggest something for Outpost
labels:
- "type: feature"
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.
- type: input
id: summary
attributes:
label: Summary
placeholder: What do you want Outpost to do?
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation
description: What's the use case? What can't you do today without this?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed Behavior
description: How should it work? Point at how Outline's web app does it, if relevant.
- type: dropdown
id: platform
attributes:
label: Platform
options:
- macOS
- iOS / iPadOS
- Both / platform-agnostic
validations:
required: true
+20
View File
@@ -0,0 +1,20 @@
name: Question
about: Ask something about setup, usage, or how Outpost works
labels:
- "type: question"
body:
- type: markdown
attributes:
value: |
Check [`SETUP.md`](../../SETUP.md) first — most "how do I get this running" questions are answered there.
- type: textarea
id: question
attributes:
label: Question
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
description: What are you trying to do? Anything you've already tried?
@@ -0,0 +1,46 @@
name: Security Vulnerability
about: Report a security vulnerability in this repository
labels:
- "type: security"
- "priority: critical"
body:
- type: markdown
attributes:
value: |
**Please do not disclose sensitive details publicly.** If this is a critical vulnerability,
email security@psmattas.com directly instead of filing this issue — see [`SECURITY.md`](../../SECURITY.md).
- type: input
id: summary
attributes:
label: Summary
placeholder: Brief description of the vulnerability
validations:
required: true
- type: dropdown
id: severity
attributes:
label: Severity
options:
- Critical — active exploit / data exposure
- High — exploitable with moderate effort
- Medium — limited impact or requires specific conditions
- Low — informational / hardening suggestion
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: What is the vulnerability and how can it be exploited?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Provide enough detail for someone to verify the issue.
- type: textarea
id: remediation
attributes:
label: Suggested Remediation
description: If you have a fix in mind, describe it here.
+38
View File
@@ -0,0 +1,38 @@
name: Pull Request
about: Standard pull request template
body:
- type: input
id: ticket
attributes:
label: Related Issue
description: "Leave blank if there isn't one."
placeholder: "Resolves #"
- type: textarea
id: summary
attributes:
label: Summary
description: "One or two sentences — what and why."
validations:
required: true
- type: textarea
id: changes
attributes:
label: Changes
value: "-\n-"
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: Self-reviewed
required: true
- label: "`OutlineKit` tests added/updated and passing (`swift test`), if this touches the REST layer"
required: false
- label: Verified in Xcode (there's no reliable CLI build for the app target)
required: true
- label: Docs updated (if applicable)
+6
View File
@@ -60,3 +60,9 @@ fastlane/report.xml
fastlane/Preview.html fastlane/Preview.html
fastlane/screenshots/**/*.png fastlane/screenshots/**/*.png
fastlane/test_output fastlane/test_output
# scripts/package-dmg.sh output
/dist/
# Local working checklist - intentionally never version controlled
/TODO.local.md
+37
View File
@@ -0,0 +1,37 @@
# Outpost Code Owners
# These users are automatically requested for review on PRs.
# Format: path @username
#
# Rules are evaluated in order, last match wins — so the specific paths
# below stay pinned to @psmattas even if `*` is ever opened up to other
# contributors/reviewers as the project grows. These are the
# supply-chain, governance, and CI-relevant files where an accidental or
# malicious change has outsized blast radius for a public repo.
* @psmattas
# Repo governance / legal — changes here affect every contributor.
/LICENSE @psmattas
/CODEOWNERS @psmattas
/CONTRIBUTING.md @psmattas
/SECURITY.md @psmattas
/SETUP.md @psmattas
# CI, issue/PR automation, review requirements — tampering here can
# bypass the protections this very file is trying to set up.
/.gitea/ @psmattas
# Dependency supply chain — a swapped or re-pinned package here can pull
# in arbitrary code at build time.
/OutlineKit/Package.swift @psmattas
/OutlineKit/Package.resolved @psmattas
/Outpost.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @psmattas
# Build/signing/versioning config and release tooling.
/Outpost.xcodeproj/project.pbxproj @psmattas
/scripts/ @psmattas
# Project direction — architecture/scope decisions shouldn't drift via a
# drive-by PR.
/CLAUDE.md @psmattas
/docs/ARCHITECTURE.md @psmattas
+71
View File
@@ -0,0 +1,71 @@
# Contributing to Outpost
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.
---
## Branching
Branch from `main` using `type/short-description`:
```bash
git checkout -b feature/document-permissions
git checkout -b fix/sidebar-context-menu
```
---
## Commit Messages
Follow the Conventional Commits standard: `type(scope): message`.
| Type | Description |
| :--- | :--- |
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation |
| `style` | Formatting |
| `refactor` | Refactor |
| `perf` | Performance |
| `test` | Tests |
| `build` | Build system |
| `ci` | CI/CD config |
| `chore` | Maintenance |
**Examples:**
- `feat(collections): add document right-click context menu`
- `fix(reader): correct off-main AppKit calls in save action`
---
## Pull Requests
- Link the related issue in your PR description, if any
- Keep PRs focused — one feature or fix per PR
- Self-review before requesting review
- Run the tests that apply to what you touched (see below) and confirm the app still launches and behaves correctly in Xcode
### Testing
- **`OutlineKit`** (the REST client package) has real unit test coverage:
```bash
cd OutlineKit && swift test
```
- **The `Outpost` app target** has no meaningful CLI build path — `xcodebuild` from the command line is not a reliable way to verify it in this project's current setup. Build and run through Xcode, and manually verify the feature you changed (and anything obviously adjacent) before opening a PR.
---
## Labels
Issues and PRs use two label prefixes:
- `type:` — what kind of change/issue this is (`type: bug`, `type: docs`, `type: security`, ...)
- `priority:` — how urgent it is (used mainly for security reports)
---
## Questions
Open an issue — this is a single-repo project, there's no separate issue tracker to route to.
@@ -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,665 @@
import Foundation
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
/// support at the existing protocol boundary, so no view model needs to know
/// the network exists:
///
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
/// cache. Always tries live first never pre-checks reachability, since
/// "did this specific request just fail" is a more honest signal than a
/// reachability monitor, and the two can disagree (captive portals,
/// flaky Wi-Fi). Falls back to the cache only on failure; a live success
/// always overwrites the cache, so staleness is bounded by "last time this
/// endpoint actually worked."
/// - **A small set of writes** (`updateDocument`, `updateCollection`,
/// pin/star/subscribe create+delete): applied optimistically against the
/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on
/// failure, then replayed in order by `flushPendingOperations()` once back
/// online. Deliberately doesn't include anything that creates new tree
/// structure (`createDocument`, `moveDocument`, `archiveDocument`,
/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) those need
/// real server-assigned ids to stay consistent with the rest of the tree,
/// and reconciling a locally-invented id with the one the server hands
/// back on sync is a much bigger problem than this pass takes on. Sharing,
/// permissions, search, and export also stay live-only read the request,
/// they need someone else's server session, not just a network.
/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips
/// attempting `live` entirely same fallback/queue paths as a real
/// failure, just chosen on purpose instead of discovered.
public actor CachingOutlineAPIClient: OutlineAPIClient {
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
private let live: OutlineAPIClient
private let cache: OfflineCacheStore
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let keyEncoder: JSONEncoder
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
self.live = live
self.cache = cache
self.defaults = defaults
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
let keyEncoder = JSONEncoder()
keyEncoder.outputFormatting = .sortedKeys
self.keyEncoder = keyEncoder
}
private var isManualOfflineModeEnabled: Bool {
defaults.bool(forKey: Self.offlineModeDefaultsKey)
}
// MARK: - Cached reads
public func documentInfo(id: String) async throws -> OutlineDocument {
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
}
public func listDocuments(
collectionId: String?,
parentDocumentId: String?,
offset: Int,
limit: Int
) async throws -> [OutlineDocument] {
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
return try await cachedFetch(key: key) {
try await self.live.listDocuments(
collectionId: collectionId,
parentDocumentId: parentDocumentId,
offset: offset,
limit: limit
)
}
}
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
}
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
try await self.live.listViewedDocuments(offset: offset, limit: limit)
}
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await cachedFetch(key: "collections:\(offset):\(limit)") {
try await self.live.listCollections(offset: offset, limit: limit)
}
}
public func collectionInfo(id: String) async throws -> OutlineCollection {
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
}
// MARK: - Queueable writes
/// The one exception to "no new tree structure while offline"
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
/// pin/star/subscribe), caches it so it's immediately readable/editable,
/// and queues the real create. On sync, the server-assigned id replaces
/// the placeholder in the cache; anything still referencing the old id
/// directly (a currently-open reader, most likely) won't follow that
/// rename automatically a known, narrow limitation, not something this
/// pass tries to solve generally.
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.createDocument(request)
await cacheDocument(result)
return result
} catch {
return await queueDocumentCreate(request)
}
}
return await queueDocumentCreate(request)
}
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateDocument(request)
await cacheDocument(result)
return result
} catch {
return try await queueDocumentUpdate(request, dueTo: error)
}
}
return try await queueDocumentUpdate(request, dueTo: nil)
}
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateCollection(request)
await cacheCollection(result)
return result
} catch {
return try await queueCollectionUpdate(request, dueTo: error)
}
}
return try await queueCollectionUpdate(request, dueTo: nil)
}
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
if !isManualOfflineModeEnabled {
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
}
return await queuePinCreate(request)
}
public func deletePin(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deletePin(id: id)
return
} catch {
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
return
}
}
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
}
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
if !isManualOfflineModeEnabled {
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
}
return await queueSubscriptionCreate(request)
}
public func deleteSubscription(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteSubscription(id: id)
return
} catch {
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
return
}
}
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
}
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
}
return await queueStarDocumentCreate(request)
}
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
}
return await queueStarCollectionCreate(request)
}
public func deleteStar(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteStar(id: id)
return
} catch {
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
return
}
}
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
}
// MARK: - Pass-through
public func authInfo() async throws -> OutlineAuthInfo {
try await live.authInfo()
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await live.searchDocuments(request)
}
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
try await live.searchDocumentTitles(request)
}
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
try await live.templatizeDocument(request)
}
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
try await live.duplicateDocument(request)
}
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
try await live.unpublishDocument(request)
}
public func archiveDocument(id: String) async throws -> OutlineDocument {
try await live.archiveDocument(id: id)
}
public func moveDocument(_ request: MoveDocumentRequest) async throws {
try await live.moveDocument(request)
}
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
try await live.deleteDocument(request)
}
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
try await live.documentInsights(request)
}
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
try await live.listRevisions(request)
}
public func exportDocument(id: String) async throws -> String {
try await live.exportDocument(id: id)
}
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
try await live.createShare(request)
}
public func shareInfo(documentId: String) async throws -> OutlineShare? {
try await live.shareInfo(documentId: documentId)
}
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
try await live.updateShare(request)
}
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
try await live.listShares(request)
}
public func revokeShare(id: String) async throws {
try await live.revokeShare(id: id)
}
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await live.listPins(request)
}
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
try await live.listSubscriptions(request)
}
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
try await live.listViews(request)
}
public func 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()
}
// MARK: - Sync management (Settings surface)
public func pendingOperations() async -> [PendingOperationSummary] {
await cache.pendingOperations().map {
PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError)
}
}
public func cacheStorageSummary() async -> CacheStorageSummary {
CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes())
}
public func clearCache() async {
await cache.clearAll()
}
/// Replays every queued operation against `live`, in the order they were
/// queued. Each is independent one failing doesn't block the rest.
public func flushPendingOperations() async -> SyncFlushSummary {
let operations = await cache.pendingOperations()
var succeeded = 0
var failed = 0
for operation in operations {
do {
try await replay(operation)
await cache.removeOperation(id: operation.id)
succeeded += 1
} catch {
await cache.recordFailure(id: operation.id, error: errorDescription(error))
failed += 1
}
}
return SyncFlushSummary(succeeded: succeeded, failed: failed)
}
/// "Full Local Sync": eagerly walks every collection and caches every
/// document's full content (not just whatever's been opened), so
/// browsing offline works for the whole workspace, not only what was
/// already viewed. Goes through `self`, not `live`, directly the
/// existing cached-read methods already do the caching as a side effect,
/// this just has to drive the walk and separately cache each document by
/// id (`listDocuments`'s cache key is the list, not the individual doc).
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 {
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
} catch {
errors.append("\(collection.name): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
}
documentsCount += documents.count
guard documents.count == limit else { break }
offset += limit
}
}
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
}
// MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
// Manual offline mode means "skip the network entirely," not just
// "prefer it" without this check, a read would still hit `live`
// (and succeed, showing content beyond whatever's cached) any time
// the device actually had a connection, defeating the point of
// deliberately testing/working as if offline.
if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
return cached
}
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
do {
let result = try await fetch()
if let data = try? encoder.encode(result) {
await cache.save(data, forKey: key)
}
return result
} catch {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
return cached
}
throw error
}
}
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
return prefix
}
return "\(prefix):\(json)"
}
private func cacheDocument(_ document: OutlineDocument) async {
if let data = try? encoder.encode(document) {
await cache.save(data, forKey: "document:\(document.id)")
}
}
private func cacheCollection(_ collection: OutlineCollection) async {
if let data = try? encoder.encode(collection) {
await cache.save(data, forKey: "collection:\(collection.id)")
}
}
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
guard let data = try? encoder.encode(payload) else { return }
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
}
/// A delete targeting a `pending-*` id can only mean "cancel the create
/// still sitting in the queue" the server has never heard of that id,
/// so queuing the delete would just fail once synced. Returns whether it
/// found (and removed) a matching create, meaning the caller is done.
private func cancelIfNeverSynced(id: String) async -> Bool {
guard id.hasPrefix("pending-") else { return false }
return await cache.removeOperation(id: id)
}
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
let pendingId = "pending-\(UUID().uuidString)"
let now = Date()
let synthesized = OutlineDocument(
id: pendingId,
title: request.title,
text: request.text,
emoji: nil,
collectionId: request.collectionId,
parentDocumentId: request.parentDocumentId,
url: "/doc/\(pendingId)",
revision: nil,
fullWidth: nil,
createdAt: now,
updatedAt: now,
publishedAt: request.publish ? now : nil,
archivedAt: nil,
deletedAt: nil
)
await cacheDocument(synthesized)
await enqueue(.createDocument, payload: request, id: pendingId)
return synthesized
}
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
let merged = OutlineDocument(
id: base.id,
title: request.title ?? base.title,
text: mergedText,
emoji: base.emoji,
collectionId: base.collectionId,
parentDocumentId: base.parentDocumentId,
url: base.url,
revision: base.revision,
fullWidth: request.fullWidth ?? base.fullWidth,
createdAt: base.createdAt,
updatedAt: Date(),
publishedAt: base.publishedAt,
archivedAt: base.archivedAt,
deletedAt: base.deletedAt
)
await cacheDocument(merged)
// Still-unsynced create for this exact document fold the edit into
// the pending create's payload instead of queuing a separate update.
// A separate update would target `merged.id`, which is still the
// `pending-*` placeholder at replay time; the server has never heard
// of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"),
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
let resolvedCreate = CreateDocumentRequest(
title: merged.title,
text: merged.text,
collectionId: createRequest.collectionId,
parentDocumentId: createRequest.parentDocumentId,
publish: createRequest.publish
)
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
return merged
}
// Fully resolved (not the original partial request) so replaying just
// this one queued operation reproduces `merged` exactly that's what
// makes coalescing a second edit onto the same queued id safe.
let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth)
await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)")
return merged
}
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let merged = OutlineCollection(
id: base.id,
name: request.name ?? base.name,
description: request.description ?? base.description,
color: base.color,
icon: base.icon,
createdAt: base.createdAt,
updatedAt: Date()
)
await cacheCollection(merged)
let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description)
await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)")
return merged
}
private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createPin, payload: request, id: pendingId)
return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil)
}
private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createSubscription, payload: request, id: pendingId)
return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event)
}
private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starDocument, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil)
}
private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starCollection, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId)
}
private func replay(_ operation: PendingOperation) async throws {
guard let kind = PendingOperationKind(rawValue: operation.kind) else {
throw OutlineAPIError.decoding(DecodingError.dataCorrupted(
DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)")
))
}
switch kind {
case .createDocument:
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request)
await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument:
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request)
await cacheDocument(result)
case .updateCollection:
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
let result = try await live.updateCollection(request)
await cacheCollection(result)
case .createPin:
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
_ = try await live.createPin(request)
case .deletePin:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deletePin(id: request.id)
case .createSubscription:
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
_ = try await live.createSubscription(request)
case .deleteSubscription:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deleteSubscription(id: request.id)
case .starDocument:
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
_ = try await live.starDocument(request)
case .deleteStar:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deleteStar(id: request.id)
case .starCollection:
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
_ = try await live.starCollection(request)
}
}
private func errorDescription(_ error: Error) -> String {
if let apiError = error as? OutlineAPIError {
switch apiError {
case .unauthorized: return "Sign-in expired."
case .notFound: return "Not found on the server."
case .server(let status, let message): return message ?? "Server error (\(status))."
case .decoding: return "Unexpected response shape."
case .transport: return "Couldn't reach the server."
case .tokenUnavailable: return "Couldn't access the saved sign-in."
}
}
return String(describing: error)
}
}
@@ -0,0 +1,104 @@
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
}
/// Used to drop a temporary `pending-*` document's cache entry once a
/// queued create syncs and the server hands back the real id the
/// placeholder key would otherwise sit around as a dead orphan forever.
public func removeCacheEntry(forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
modelContext.delete(existing)
try? modelContext.save()
}
public func itemCount() -> Int {
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
}
public func totalBytes() -> Int {
let descriptor = FetchDescriptor<CachedPayload>()
let rows = (try? modelContext.fetch(descriptor)) ?? []
return rows.reduce(0) { $0 + $1.payload.count }
}
public func clearAll() {
let descriptor = FetchDescriptor<CachedPayload>()
guard let rows = try? modelContext.fetch(descriptor) else { return }
rows.forEach { modelContext.delete($0) }
try? modelContext.save()
}
// MARK: - Offline write queue
/// Upserts by `id` a second call with the same id (an edit coalescing
/// onto a still-unsynced edit, or a create being cancelled by its own
/// synthesized id) replaces the row in place rather than piling up.
public func enqueueOperation(id: String, kind: String, payload: Data) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
if let existing = try? modelContext.fetch(descriptor).first {
existing.kind = kind
existing.payload = payload
existing.lastError = nil
existing.attemptCount = 0
} else {
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
}
try? modelContext.save()
}
public func pendingOperations() -> [PendingOperation] {
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Returns whether a matching operation was actually found and removed
/// callers use this to detect "this targeted something that never made
/// it to the server in the first place" and skip queuing a delete.
@discardableResult
public func removeOperation(id: String) -> Bool {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
modelContext.delete(existing)
try? modelContext.save()
return true
}
public func recordFailure(id: String, error: String) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
existing.lastAttemptAt = Date()
existing.lastError = error
existing.attemptCount += 1
try? modelContext.save()
}
}
@@ -0,0 +1,40 @@
import Foundation
import SwiftData
/// A queued mutation made while offline, waiting to replay against the live
/// server. `id` is deliberately overloaded: for actions that create a new
/// server-side record (pin, star, subscription), it's also the synthesized
/// placeholder id handed back to the caller immediately so a matching
/// delete queued before that create ever syncs can cancel both out by id
/// instead of hitting a server that's never heard of the placeholder. For
/// actions that edit an existing record (document/collection updates), it's
/// deterministic per target id, so a second edit before the first syncs
/// coalesces into one queued operation instead of piling up.
@Model
public final class PendingOperation {
@Attribute(.unique) public var id: String
public var kind: String
public var payload: Data
public var createdAt: Date
public var lastAttemptAt: Date?
public var lastError: String?
public var attemptCount: Int
public init(
id: String,
kind: String,
payload: Data,
createdAt: Date,
lastAttemptAt: Date? = nil,
lastError: String? = nil,
attemptCount: Int = 0
) {
self.id = id
self.kind = kind
self.payload = payload
self.createdAt = createdAt
self.lastAttemptAt = lastAttemptAt
self.lastError = lastError
self.attemptCount = attemptCount
}
}
@@ -0,0 +1,50 @@
import Foundation
/// UI-facing snapshot of a queued offline mutation deliberately not the
/// `@Model` type itself, so Settings can display it without holding a
/// reference into the SwiftData store.
public struct PendingOperationSummary: Identifiable, Sendable {
public let id: String
public let kind: String
public let createdAt: Date
public let attemptCount: Int
public let lastError: String?
}
public struct CacheStorageSummary: Sendable {
public let itemCount: Int
public let totalBytes: Int
}
public struct SyncFlushSummary: Sendable {
public let succeeded: Int
public let failed: Int
}
public struct FullSyncSummary: Sendable {
public let collectionsCount: Int
public let documentsCount: Int
public let errors: [String]
public let finishedAt: Date
}
/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and
/// replay later. Deliberately a small, explicit set see its doc comment
/// for what's excluded and why.
enum PendingOperationKind: String, Codable, Sendable {
case createDocument
case updateDocument
case updateCollection
case createPin
case deletePin
case createSubscription
case deleteSubscription
case starDocument
case deleteStar
case starCollection
}
/// Shared payload shape for the delete-by-id queueable operations.
struct IDPayload: Codable, Sendable {
let id: String
}
@@ -9,6 +9,10 @@ public protocol OutlineAPIClient: Sendable {
func documentInfo(id: String) async throws -> OutlineDocument func documentInfo(id: String) async throws -> OutlineDocument
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument]
/// Richer filtering (sort/direction/userId) for the Home page's tabs. See `DocumentsListRequest`.
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]
/// Full-text search with snippets/ranking. Backed by `documents.search`. /// Full-text search with snippets/ranking. Backed by `documents.search`.
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
/// Title-only search faster, no snippets. Backed by `documents.search_titles`. /// Title-only search faster, no snippets. Backed by `documents.search_titles`.
@@ -31,6 +35,8 @@ public protocol OutlineAPIClient: Sendable {
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
func shareInfo(documentId: String) async throws -> OutlineShare? func shareInfo(documentId: String) async throws -> OutlineShare?
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare]
func revokeShare(id: String) async throws
/// See `OutlinePin` best-effort, not in the vendored spec. /// See `OutlinePin` best-effort, not in the vendored spec.
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin]
@@ -42,6 +48,14 @@ public protocol OutlineAPIClient: Sendable {
/// Historical view records, not live presence. Backed by `views.list`. /// Historical view records, not live presence. Backed by `views.list`.
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
/// See `OutlineMembership`/`OutlineDocumentMember` `add` is confirmed
/// from Outline's official docs, the rest are best-effort.
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember]
/// For searching workspace members to invite. Backed by `users.list`.
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser]
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
func collectionInfo(id: String) async throws -> OutlineCollection func collectionInfo(id: String) async throws -> OutlineCollection
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
@@ -51,6 +51,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
) )
} }
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await post("documents.list", body: request)
}
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await post("documents.search", body: request) try await post("documents.search", body: request)
} }
@@ -113,23 +121,38 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
} }
public func shareInfo(documentId: String) async throws -> OutlineShare? { public func shareInfo(documentId: String) async throws -> OutlineShare? {
do { // Real shape confirmed against a live server: `data` is
return try await post("shares.info", body: ShareInfoRequest(documentId: documentId)) // `{ shares: [...] }`, not the bare share object the docs imply
} catch OutlineAPIError.notFound { // same pattern as `pins.list`. A document could in principle have
return nil // more than one share record; the first is what the reader's
} // share sheet cares about.
let payload: SharesInfoPayload? = try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId))
return payload?.shares.first
} }
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
try await post("shares.update", body: request) try await post("shares.update", body: request)
} }
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
try await post("shares.list", body: request)
}
public func revokeShare(id: String) async throws {
try await postForSuccess("shares.revoke", body: StarIDParams(id: id))
}
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
try await post("pins.create", body: request) try await post("pins.create", body: request)
} }
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await post("pins.list", body: request) // `data` here is `{ pins: [...], documents: [...] }`, not a bare
// array confirmed against a live server. Decoding straight to
// `[OutlinePin]` throws on every call, which `try?` at call sites
// swallows silently, so pins never showed up anywhere.
let payload: PinsListPayload = try await post("pins.list", body: request)
return payload.pins
} }
public func deletePin(id: String) async throws { public func deletePin(id: String) async throws {
@@ -152,6 +175,22 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("views.list", body: request) try await post("views.list", body: request)
} }
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await post("documents.add_user", body: request)
}
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
try await postForSuccess("documents.remove_user", body: request)
}
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
try await post("documents.users", body: request)
}
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
try await post("users.list", body: request)
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit)) try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
} }
@@ -230,6 +269,52 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
} }
} }
/// Like `post(_:body:)`, but for endpoints where "no result" comes back
/// as a 200 with a completely empty body instead of a real 404
/// confirmed against a live server for `shares.info` (no share yet for
/// a given document). A 404 is still treated as nil too.
private func postOptional<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response? {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
}
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try encoder.encode(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)
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
return nil
default:
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
guard !data.isEmpty else { return nil }
do {
return try decoder.decode(OutlineEnvelope<Response>.self, from: data).data
} catch {
throw OutlineAPIError.decoding(error)
}
}
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }` /// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
/// (e.g. `collections.delete`) `post(_:body:)`'s envelope decode doesn't fit. /// (e.g. `collections.delete`) `post(_:body:)`'s envelope decode doesn't fit.
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws { private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
@@ -286,6 +371,15 @@ private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument] let documents: [OutlineDocument]
} }
private struct PinsListPayload: Decodable {
let pins: [OutlinePin]
let documents: [OutlineDocument]
}
private struct SharesInfoPayload: Decodable {
let shares: [OutlineShare]
}
private struct ListStarsResponse: Decodable { private struct ListStarsResponse: Decodable {
let stars: [OutlineStar] let stars: [OutlineStar]
} }
@@ -314,6 +408,11 @@ private struct DocumentListParams: Encodable {
let limit: Int let limit: Int
} }
private struct PaginationParams: Encodable {
let offset: Int
let limit: Int
}
private struct CollectionListParams: Encodable { private struct CollectionListParams: Encodable {
let offset: Int let offset: Int
let limit: Int let limit: Int
@@ -0,0 +1,31 @@
import Foundation
/// A user's explicit permission grant on a document, backed by
/// `documents.add_user`/`documents.remove_user`. Not in the vendored spec
/// only `documents.add_user`'s shape is confirmed from Outline's official
/// docs; `remove` and the response shape here follow the create/delete and
/// `{data: ...}` conventions used throughout the rest of this API, but
/// aren't verified against a live server yet.
public struct OutlineMembership: Decodable, Identifiable, Sendable {
public let id: String
public let userId: String
public let documentId: String?
/// `"read"` or `"read_write"` per the documented enum.
public let permission: String
}
/// A workspace member as returned by `documents.users` in the context of a
/// specific document same identity fields as `OutlineUser`, plus (if the
/// server includes it) the permission they hold on that document. Modeled
/// separately from `OutlineUser` rather than adding an optional field there,
/// since `permission` only makes sense in this document-scoped context.
/// Speculative `documents.users` isn't in the vendored spec, its name is
/// inferred from Outline's usual `<resource>.<verb>` convention and hasn't
/// been confirmed against a live server.
public struct OutlineDocumentMember: Decodable, Identifiable, Sendable {
public let id: String
public let name: String
public let email: String?
public let avatarUrl: String?
public let permission: String?
}
@@ -1,11 +1,11 @@
import Foundation import Foundation
/// A document pinned to the top of a collection (or the team home), backed by /// A document pinned to the top of a collection, or to team Home when
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`) /// `collectionId` is `nil`. Backed by `pins.*` not in the vendored OpenAPI
/// that spec has no `Pins` tag at all but the endpoint exists on Outline's /// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed /// confirmed real against a live server's network traffic. `collectionId: nil`
/// from general knowledge of Outline's API, not verified against this spec; /// is what "Pin to Home" actually sends; a non-nil value is "Pin to
/// treat field names as best-effort until confirmed against a live server. /// Collection", a distinct action.
public struct OutlinePin: Decodable, Identifiable, Sendable { public struct OutlinePin: Decodable, Identifiable, Sendable {
public let id: String public let id: String
public let documentId: String public let documentId: String
@@ -1,10 +1,41 @@
import Foundation import Foundation
/// A public share link for a document or collection, backed by `shares.*`. /// A public share link for a document or collection, backed by `shares.*`.
///
/// Only `id` and `published` are guaranteed present on every real share
/// object everything else is modeled as optional even where the official
/// API docs don't explicitly mark it nullable, since self-hosted servers
/// have repeatedly diverged from the hosted app's documented response shape
/// (see `OutlinePin`'s history) and a single unexpectedly-null field used to
/// crash this decode entirely ("Got an unexpected response from the
/// server").
public struct OutlineShare: Decodable, Identifiable, Sendable { public struct OutlineShare: Decodable, Identifiable, Sendable {
public let id: String public let id: String
public let published: Bool
public let documentId: String? public let documentId: String?
public let collectionId: String? public let collectionId: String?
public let url: String public let documentTitle: String?
public let published: Bool public let documentUrl: String?
public let sourceTitle: String?
public let sourcePath: String?
public let urlId: String?
public let url: String?
public let domain: String?
/// Overrides the source document/collection title on the publicly
/// shared page, if set.
public let title: String?
/// Overrides the workspace branding icon on the publicly shared page,
/// if set.
public let iconUrl: String?
public let includeChildDocuments: Bool?
public let allowSubscriptions: Bool?
public let allowIndexing: Bool?
public let showLastUpdated: Bool?
public let showTOC: Bool?
public let views: Int?
public let createdBy: OutlineUser?
public let createdAt: Date?
public let updatedAt: Date?
public let lastAccessedAt: Date?
} }
@@ -0,0 +1,16 @@
import Foundation
/// See `OutlineMembership`. Confirmed shape from Outline's official
/// `documents.add_user` docs.
public struct AddDocumentUserRequest: Encodable, Sendable {
public let id: String
public let userId: String
/// `"read"` or `"read_write"`.
public let permission: String
public init(id: String, userId: String, permission: String) {
self.id = id
self.userId = userId
self.permission = permission
}
}
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct CreateDocumentRequest: Encodable, Sendable { public struct CreateDocumentRequest: Codable, Sendable {
public let title: String public let title: String
public let text: String public let text: String
public let collectionId: String public let collectionId: String
@@ -1,7 +1,8 @@
import Foundation import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec. /// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
public struct CreatePinRequest: Encodable, Sendable { /// Collection" these are distinct actions on the real server.
public struct CreatePinRequest: Codable, Sendable {
public let documentId: String public let documentId: String
public let collectionId: String? public let collectionId: String?
@@ -1,7 +1,7 @@
import Foundation import Foundation
/// See `OutlineSubscription` best-effort shape, not in the vendored spec. /// 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 documentId: String
public let event: String public let event: String
@@ -0,0 +1,29 @@
import Foundation
/// Richer `documents.list` query than `OutlineAPIClient.listDocuments` covers
/// (that one's kept as-is for its existing simple callers) adds the
/// sort/direction/userId filters the Home page's tabs need.
public struct DocumentsListRequest: Encodable, Sendable {
public let collectionId: String?
public let userId: String?
public let sort: String?
public let direction: String?
public let offset: Int
public let limit: Int
public init(
collectionId: String? = nil,
userId: String? = nil,
sort: String? = nil,
direction: String? = nil,
offset: Int = 0,
limit: Int = 25
) {
self.collectionId = collectionId
self.userId = userId
self.sort = sort
self.direction = direction
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,17 @@
import Foundation
/// See `OutlineDocumentMember`. Speculative `documents.users` isn't in
/// the vendored spec.
public struct ListDocumentUsersRequest: Encodable, Sendable {
public let id: String
public let query: String?
public let offset: Int
public let limit: Int
public init(id: String, query: String? = nil, offset: Int = 0, limit: Int = 25) {
self.id = id
self.query = query
self.offset = offset
self.limit = limit
}
}
@@ -1,6 +1,6 @@
import Foundation import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec. /// See `OutlinePin`. `collectionId: nil` lists Home pins only.
public struct ListPinsRequest: Encodable, Sendable { public struct ListPinsRequest: Encodable, Sendable {
public let collectionId: String? public let collectionId: String?
@@ -0,0 +1,18 @@
import Foundation
public struct ListSharesRequest: Encodable, Sendable {
public let offset: Int
public let limit: Int
public let sort: String?
public let direction: String?
/// Filter to shared documents matching a search query.
public let query: String?
public init(offset: Int = 0, limit: Int = 25, sort: String? = nil, direction: String? = nil, query: String? = nil) {
self.offset = offset
self.limit = limit
self.sort = sort
self.direction = direction
self.query = query
}
}
@@ -0,0 +1,15 @@
import Foundation
/// For searching workspace members to invite to a document. Backed by
/// `users.list`.
public struct ListUsersRequest: Encodable, Sendable {
public let query: String?
public let offset: Int
public let limit: Int
public init(query: String? = nil, offset: Int = 0, limit: Int = 25) {
self.query = query
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,14 @@
import Foundation
/// See `OutlineMembership`. Speculative follows the create/delete pairing
/// convention used elsewhere in this API (`documents.add_user` /
/// `documents.remove_user`), not confirmed against a live server yet.
public struct RemoveDocumentUserRequest: Encodable, Sendable {
public let id: String
public let userId: String
public init(id: String, userId: String) {
self.id = id
self.userId = userId
}
}
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct StarCollectionRequest: Encodable, Sendable { public struct StarCollectionRequest: Codable, Sendable {
public let collectionId: String public let collectionId: String
public init(collectionId: String) { public init(collectionId: String) {
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct StarDocumentRequest: Encodable, Sendable { public struct StarDocumentRequest: Codable, Sendable {
public let documentId: String public let documentId: String
public init(documentId: String) { public init(documentId: String) {
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct UpdateCollectionRequest: Encodable, Sendable { public struct UpdateCollectionRequest: Codable, Sendable {
public let id: String public let id: String
public let name: String? public let name: String?
public let description: String? public let description: String?
@@ -1,18 +1,12 @@
import Foundation import Foundation
public struct UpdateDocumentRequest: Encodable, Sendable { public struct UpdateDocumentRequest: Codable, Sendable {
public let id: String public let id: String
public let title: String? public let title: String?
public let text: String? public let text: String?
public let append: Bool? public let append: Bool?
public let fullWidth: Bool? public let fullWidth: Bool?
public let insightsEnabled: Bool? public let insightsEnabled: Bool?
/// Not in the vendored spec's `Document`/`documents.update` shape at all
/// (only a workspace-level `documentEmbeds` flag exists there) included
/// speculatively since the field may exist on newer self-hosted servers.
/// Unrecognized fields are typically ignored server-side rather than
/// rejected, so this is low-risk even if unsupported.
public let documentEmbeds: Bool?
public init( public init(
id: String, id: String,
@@ -20,8 +14,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
text: String? = nil, text: String? = nil,
append: Bool? = nil, append: Bool? = nil,
fullWidth: Bool? = nil, fullWidth: Bool? = nil,
insightsEnabled: Bool? = nil, insightsEnabled: Bool? = nil
documentEmbeds: Bool? = nil
) { ) {
self.id = id self.id = id
self.title = title self.title = title
@@ -29,6 +22,5 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
self.append = append self.append = append
self.fullWidth = fullWidth self.fullWidth = fullWidth
self.insightsEnabled = insightsEnabled self.insightsEnabled = insightsEnabled
self.documentEmbeds = documentEmbeds
} }
} }
@@ -3,9 +3,16 @@ import Foundation
public struct UpdateShareRequest: Encodable, Sendable { public struct UpdateShareRequest: Encodable, Sendable {
public let id: String public let id: String
public let published: Bool public let published: Bool
/// Overrides the title displayed on the publicly shared page. `nil`
/// leaves it unset in the payload (server keeps whatever it already
/// has) rather than clearing it send an empty string to clear.
public let title: String?
public let iconUrl: String?
public init(id: String, published: Bool) { public init(id: String, published: Bool, title: String? = nil, iconUrl: String? = nil) {
self.id = id self.id = id
self.published = published self.published = published
self.title = title
self.iconUrl = iconUrl
} }
} }
@@ -0,0 +1,500 @@
import XCTest
@testable import OutlineKit
private struct NotStubbed: Error {}
/// Conforms to the full protocol (so it can stand in for `live`), but only
/// the two methods under test in this file have real behavior everything
/// else throws loudly if a test accidentally exercises it.
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)?
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
var deletePinHandler: (@Sendable (String) async throws -> Void)?
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
func documentInfo(id: String) async throws -> OutlineDocument {
guard let handler = documentInfoHandler else { throw NotStubbed() }
return try await handler(id)
}
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
guard let handler = listDocumentsHandler else { throw NotStubbed() }
return try await handler(collectionId, parentDocumentId, offset, limit)
}
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
func 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 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() }
}
private struct StubTransportError: Error {}
final class CachingOutlineAPIClientTests: XCTestCase {
private func makeCache() throws -> OfflineCacheStore {
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
}
private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument {
OutlineDocument(
id: id,
title: title,
text: "body",
url: "/doc/\(id)",
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection {
OutlineCollection(
id: id,
name: name,
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
func testDocumentInfoWritesThroughToCacheOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
stub.documentInfoHandler = { _ in document }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
}
func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
var callCount = 0
stub.documentInfoHandler = { _ in
callCount += 1
if callCount == 1 { return document }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
// First call succeeds and populates the cache.
_ = try await sut.documentInfo(id: "doc-1")
// Second call fails live should transparently return the cached value.
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
XCTAssertEqual(callCount, 2)
}
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
do {
_ = try await sut.documentInfo(id: "doc-1")
XCTFail("Expected an error")
} catch is StubTransportError {
// expected
}
}
func testListCollectionsFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let collections = [makeCollection()]
var callCount = 0
stub.listCollectionsHandler = { _, _ in
callCount += 1
if callCount == 1 { return collections }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.listCollections(offset: 0, limit: 25)
let result = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(result, collections)
}
func testDifferentDocumentIdsAreCachedSeparately() async throws {
let stub = StubOutlineAPIClient()
let docOne = makeDocument(id: "doc-1", title: "One")
let docTwo = makeDocument(id: "doc-2", title: "Two")
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.documentInfo(id: "doc-2")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedOne = try await sut.documentInfo(id: "doc-1")
let cachedTwo = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedOne, docOne)
XCTAssertEqual(cachedTwo, docTwo)
}
// MARK: - Offline writes
func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
// Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1")
let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
XCTAssertEqual(updated.title, "Edited Offline")
// Re-opening the document (still offline) should reflect the optimistic edit.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(reopened.title, "Edited Offline")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "updateDocument")
}
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
XCTFail("Expected an error")
} catch is StubTransportError {
// expected nothing to optimistically merge onto
}
}
func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Second Edit"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
}
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
try await sut.deletePin(id: pin.id)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
// Network's back replay should now succeed.
stub.updateDocumentHandler = { request in
self.makeDocument(id: request.id, title: request.title ?? "")
}
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
// Still offline replay should fail and keep the operation queued.
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 0)
XCTAssertEqual(summary.failed, 1)
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.attemptCount, 1)
XCTAssertNotNil(pending.first?.lastError)
}
func testManualOfflineModeSkipsLiveEntirely() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline")
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let stub = StubOutlineAPIClient()
var liveCallCount = 0
stub.createPinHandler = { _ in
liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call")
XCTAssertTrue(pin.id.hasPrefix("pending-"))
}
/// Regression test: manual offline mode used to only gate writes
/// reads (listCollections, documentInfo, etc.) still hit `live` first
/// any time the device actually had a connection, silently defeating
/// "skip the network entirely" for the one thing that mattered most:
/// the sidebar showing more than what was actually cached.
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
let stub = StubOutlineAPIClient()
let cachedCollection = makeCollection(id: "col-1")
var liveCallCount = 0
stub.listCollectionsHandler = { _, _ in
liveCallCount += 1
return [cachedCollection]
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
// Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(firstResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1)
// Flip manual offline mode on, still "connected" (stub would happily
// answer) the live call must not be attempted at all.
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(secondResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
}
func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary()
XCTAssertEqual(summary.itemCount, 1)
XCTAssertGreaterThan(summary.totalBytes, 0)
await sut.clearCache()
let clearedSummary = await sut.cacheStorageSummary()
XCTAssertEqual(clearedSummary.itemCount, 0)
}
// MARK: - Full sync
func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws {
let stub = StubOutlineAPIClient()
var requestedLimits: [Int] = []
// 105 collections across two pages (100 + 5) regression test for a
// real bug: this used to ask for `limit: 250` in one shot, which
// Outline's server rejects outright ("Pagination limit is too large
// (max 100)"), turning the whole sync into a single silent failure.
stub.listCollectionsHandler = { offset, limit in
requestedLimits.append(limit)
let remaining = max(0, 105 - offset)
let count = min(limit, remaining)
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
}
stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let summary = await sut.performFullSync()
XCTAssertEqual(summary.collectionsCount, 105)
XCTAssertTrue(summary.errors.isEmpty)
XCTAssertTrue(requestedLimits.allSatisfy { $0 <= 100 }, "requested limits: \(requestedLimits)")
}
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
stub.listDocumentsHandler = { _, _, offset, _ in
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2)
// A plain documentInfo read (no live call available) should now hit
// the cache full sync populated, not just the list-shaped cache key.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedDoc = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedDoc.id, "doc-2")
}
// MARK: - Offline document creation
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
XCTAssertTrue(created.id.hasPrefix("pending-"))
XCTAssertEqual(created.title, "New Doc")
XCTAssertEqual(created.text, "hello")
// It's immediately readable, same as any other cached document.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "New Doc")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "createDocument")
}
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
)
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
XCTAssertEqual(pending.first?.kind, "createDocument")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "Real Title")
XCTAssertEqual(reopened.text, "Real body")
}
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
stub.createDocumentHandler = { _ in realDocument }
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
// The real document is now readable under its real id...
stub.documentInfoHandler = { _ in throw StubTransportError() }
let byRealId = try await sut.documentInfo(id: "real-server-id")
XCTAssertEqual(byRealId.id, "real-server-id")
// ...and the placeholder is gone rather than left as a dead orphan.
do {
_ = try await sut.documentInfo(id: created.id)
XCTFail("Expected the placeholder cache entry to have been removed")
} catch {
// expected nothing left under the old id
}
}
}
@@ -280,6 +280,65 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(decodedBody.parentDocumentId, "doc-1") XCTAssertEqual(decodedBody.parentDocumentId, "doc-1")
} }
func testDocumentsListSendsSortDirectionAndUserId() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": [] }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
_ = try await client.documentsList(
DocumentsListRequest(userId: "user-1", sort: "updatedAt", direction: "DESC")
)
struct SentBody: Decodable {
let userId: String?
let sort: String?
let direction: String?
}
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
XCTAssertEqual(decodedBody.userId, "user-1")
XCTAssertEqual(decodedBody.sort, "updatedAt")
XCTAssertEqual(decodedBody.direction, "DESC")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.list")
}
func testListViewedDocumentsDecodesDocuments() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "doc-1",
"title": "Hello",
"text": "World",
"url": "/doc/hello-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let documents = try await client.listViewedDocuments(offset: 0, limit: 25)
XCTAssertEqual(documents.first?.id, "doc-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.viewed")
}
func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws { func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """
@@ -610,6 +669,117 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create") XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
} }
func testShareInfoDecodesRealShareArrayShape() async throws {
// Real shape confirmed against a live server `data` is
// `{ shares: [...] }`, not the bare share object the official docs
// imply (same pattern as `pins.list`). This is the exact payload
// captured for an existing, unpublished share.
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"shares": [
{
"id": "861559ac-906b-4dec-9c1f-3a2d2000745d",
"sourceTitle": "Test Document 3",
"sourcePath": "/doc/test-document-3-VHxABl5RaD",
"collectionId": null,
"documentId": "b1196971-4239-4e35-970f-7fbbc60af044",
"documentTitle": "Test Document 3",
"documentUrl": "/doc/test-document-3-VHxABl5RaD",
"published": false,
"url": "https://docs.psmattas.com/s/861559ac-906b-4dec-9c1f-3a2d2000745d",
"urlId": null,
"createdBy": {
"id": "1e2ef39c-aa82-475b-b5af-d76bb4f023ed",
"name": "Puranjay Savar Mattas",
"avatarUrl": "/api/files.get?key=public/avatar.png",
"color": "#1c9152",
"role": "admin",
"isSuspended": false,
"createdAt": "2025-07-30T17:36:58.560Z",
"updatedAt": "2026-08-14T16:47:45.037Z",
"deletedAt": null,
"lastActiveAt": "2026-08-14T16:47:45.037Z",
"timezone": "Europe/Dublin"
},
"includeChildDocuments": false,
"allowIndexing": false,
"allowSubscriptions": true,
"showLastUpdated": false,
"showTOC": false,
"title": null,
"iconUrl": null,
"views": 0,
"domain": null,
"createdAt": "2026-08-14T16:49:40.146Z",
"updatedAt": "2026-08-14T16:49:40.146Z"
}
]
},
"policies": [
{ "id": "861559ac-906b-4dec-9c1f-3a2d2000745d", "abilities": { "read": true, "update": false, "revoke": true } }
],
"status": 200,
"ok": true
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertEqual(share?.id, "861559ac-906b-4dec-9c1f-3a2d2000745d")
XCTAssertEqual(share?.published, false)
XCTAssertEqual(share?.views, 0)
XCTAssertEqual(share?.createdBy?.name, "Puranjay Savar Mattas")
XCTAssertEqual(share?.documentId, "b1196971-4239-4e35-970f-7fbbc60af044")
}
func testListSharesDecodesSharesArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{ "id": "share-1", "documentId": "doc-1", "collectionId": null, "url": "https://outline.example.com/s/share-1", "published": true }
],
"pagination": { "offset": 0, "limit": 25 }
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let shares = try await client.listShares(ListSharesRequest())
XCTAssertEqual(shares.first?.id, "share-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.list")
}
func testRevokeShareSendsRequest() 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.revokeShare(id: "share-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.revoke")
}
func testShareInfoReturnsNilOnNotFound() async throws { func testShareInfoReturnsNilOnNotFound() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.statusCode = 404 httpClient.statusCode = 404
@@ -628,6 +798,24 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertNil(share) XCTAssertNil(share)
} }
func testShareInfoReturnsNilOnEmptyBody() async throws {
// Confirmed against a live server: shares.info returns a 200 with a
// completely empty body (not a 404) when no share exists yet for a
// given document.
let httpClient = MockHTTPClient()
httpClient.responseData = Data()
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertNil(share)
}
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws { func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """
@@ -676,6 +864,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create") XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
} }
func testListPinsDecodesNestedPinsArray() async throws {
// Real shape confirmed against a live server: `data` is
// `{ pins: [...], documents: [...] }`, not a bare array.
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"pagination": { "limit": 25, "offset": 0 },
"data": {
"pins": [
{ "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" }
],
"documents": []
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let pins = try await client.listPins(ListPinsRequest(collectionId: nil))
XCTAssertEqual(pins.first?.id, "pin-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list")
}
func testCreateSubscriptionDecodesSubscription() async throws { func testCreateSubscriptionDecodesSubscription() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """
@@ -694,6 +910,89 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create") XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create")
} }
func testAddDocumentUserDecodesMembership() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": { "id": "mem-1", "userId": "user-1", "documentId": "doc-1", "permission": "read" } }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let membership = try await client.addDocumentUser(
AddDocumentUserRequest(id: "doc-1", userId: "user-1", permission: "read")
)
XCTAssertEqual(membership.id, "mem-1")
XCTAssertEqual(membership.permission, "read")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.add_user")
}
func testRemoveDocumentUserSendsRequest() 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.removeDocumentUser(RemoveDocumentUserRequest(id: "doc-1", userId: "user-1"))
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.remove_user")
}
func testDocumentUsersDecodesMembersArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "avatarUrl": null, "permission": "read_write" }
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let members = try await client.documentUsers(ListDocumentUsersRequest(id: "doc-1"))
XCTAssertEqual(members.first?.name, "Jane Doe")
XCTAssertEqual(members.first?.permission, "read_write")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.users")
}
func testListUsersDecodesUsersArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "role": "member" }
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let users = try await client.listUsers(ListUsersRequest(query: "Jane"))
XCTAssertEqual(users.first?.name, "Jane Doe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
}
func testMissingTokenThrowsTokenUnavailable() async throws { func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient( let client = LiveOutlineAPIClient(
+2 -2
View File
@@ -408,7 +408,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.1; MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -453,7 +453,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.1; MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Outpost.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
</dict>
</plist>
+15 -5
View File
@@ -2,11 +2,15 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
struct AboutView: View { /// Bare content (icon, name, version, links) with no window chrome reused
/// by both the standalone "About Outpost" window (`AboutView`, the standard
/// macOS app-menu affordance) and the Settings page's own About section, so
/// the two can't drift out of sync.
struct AboutInfoView: View {
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")! private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")! 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" Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
} }
@@ -15,7 +19,7 @@ struct AboutView: View {
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`. /// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
private let releaseStage = "ALPHA" private let releaseStage = "ALPHA"
private var versionString: String { var versionString: String {
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1" let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)" let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
@@ -66,8 +70,6 @@ struct AboutView: View {
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
.padding(32)
.frame(width: 320)
} }
// No Sparkle-style in-app updater yet this just opens the releases page // No Sparkle-style in-app updater yet this just opens the releases page
@@ -76,4 +78,12 @@ struct AboutView: View {
NSWorkspace.shared.open(releasesURL) NSWorkspace.shared.open(releasesURL)
} }
} }
struct AboutView: View {
var body: some View {
AboutInfoView()
.padding(32)
.frame(width: 320)
}
}
#endif #endif
+13 -2
View File
@@ -1,5 +1,6 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
import OutlineKit
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label /// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
/// contains the avatar image reliably broke its own sizing on click (even after /// contains the avatar image reliably broke its own sizing on click (even after
@@ -7,10 +8,11 @@ import SwiftUI
/// label rendering, which a `Button` doesn't go through. /// label rendering, which a `Button` doesn't go through.
struct AccountFooter: View { struct AccountFooter: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@Environment(\.openSettings) private var openSettings
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var isMenuPresented = false @State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false @State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false @State private var isShowingProfile = false
@@ -93,8 +95,17 @@ struct AccountFooter: View {
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 4)
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
.padding(.horizontal, 6)
.padding(.vertical, 4)
menuItem("Profile…") { isShowingProfile = true } menuItem("Profile…") { isShowingProfile = true }
menuItem("Preferences…") { openSettings() } // Deferred a tick: setting this synchronously in the same call
// that dismisses this popover collides two AppKit window/layer
// transactions in the same runloop turn (visible in the console
// as "Invalid attempt to open a new transaction during CA
// commit") letting the popover's dismissal finish first avoids it.
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
Divider() Divider()
@@ -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,41 @@
#if os(macOS)
import SwiftUI
/// 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.
struct SettingsSidebarList: View {
@Binding var selection: SettingsSection?
let onDone: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Settings")
.font(.headline)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
List(SettingsSection.allCases, selection: $selection) { section in
Label(section.title, systemImage: section.icon)
.tag(section)
}
.listStyle(.sidebar)
Divider()
Button("Done", action: onDone)
.keyboardShortcut(.cancelAction)
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.padding(12)
}
}
}
#endif
+420
View File
@@ -0,0 +1,420 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Settings *detail* content for one section the section list itself now
/// lives in `ContentView_macOS`'s real sidebar (swapped in over the
/// collections tree while `AppNavigation.isShowingSettings` is set, not a
/// separate mini sidebar of its own), so this view only ever renders
/// whichever section is currently selected.
struct SettingsView: View {
let section: SettingsSection
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false
@State private var isShowingLogoutConfirmation = false
@State private var isShowingAdvancedWarning = false
@State private var storageSummary: CacheStorageSummary?
@State private var pendingOperations: [PendingOperationSummary] = []
@State private var isSyncing = false
@State private var isClearingCache = false
@State private var didClearCache = false
@State private var lastFullSyncSummary: FullSyncSummary?
@State private var lastFlushSummary: SyncFlushSummary?
/// Full Local Sync and cache-clearing both need a real connection to be
/// safe clearing while offline (or letting Full Local Sync think it
/// should be running) can leave the app with nothing local to show and
/// no way to refetch it. "Offline" here means either a real dropped
/// connection or the user's own manual toggle both leave the app with
/// no server to talk to.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
// GeometryReader + `minHeight` (not `maxHeight`) is the actual fix for
// "center short content inside a ScrollView" a ScrollView proposes
// effectively unbounded height to its content, so `maxHeight: .infinity`
// alone just resolves to the content's own intrinsic size and does
// nothing; forcing a `minHeight` equal to the real viewport height is
// what gives `aboutDetail`'s own centered alignment somewhere to
// actually center within. Harmless for the other (already
// top/leading-aligned) sections short ones just get blank space
// below, same as before.
GeometryReader { geometry in
ScrollView {
sectionDetail
.padding(28)
.frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
.task { await refreshSyncState() }
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
}
@ViewBuilder
private var sectionDetail: some View {
switch section {
case .appearance: appearanceDetail
case .account: accountDetail
case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail
case .about: aboutDetail
}
}
private var sectionHeader: some View {
Text(section.title)
.font(.title.bold())
}
// MARK: - Appearance
private var appearanceDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 320)
}
}
// MARK: - Account
private var accountDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
VStack(alignment: .leading, spacing: 10) {
labeledRow("Signed in as", session.userName ?? "")
if let email = session.userEmail {
labeledRow("Email", email)
}
if let teamName = session.teamName {
labeledRow("Workspace", teamName)
}
}
.frame(maxWidth: 420)
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
}
}
// MARK: - Offline & Sync
private var offlineSyncDetail: some View {
VStack(alignment: .leading, spacing: 20) {
sectionHeader
VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled)
.disabled(!isEffectivelyOnline)
Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline. Runs automatically in the background once on; no need to trigger it by hand.")
.font(.caption)
.foregroundStyle(.secondary)
if !isEffectivelyOnline {
Text("Requires an internet connection to turn on or off.")
.font(.caption2)
.foregroundStyle(.orange)
}
}
.frame(maxWidth: 480, alignment: .leading)
.help(isEffectivelyOnline ? "" : "Full Local Sync needs a real connection — it can't safely turn on (or off) while offline.")
if isFullLocalSyncEnabled {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
if isSyncing {
ProgressView().controlSize(.small)
Text("Syncing…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Button("Sync Now") { Task { await runFullSync() } }
.controlSize(.small)
.disabled(!isEffectivelyOnline)
if let lastFullSyncSummary {
Text(fullSyncSummaryText(lastFullSyncSummary))
.font(.caption)
.foregroundStyle(lastFullSyncSummary.errors.isEmpty ? Color.secondary : Color.red)
}
}
}
if let lastFullSyncSummary, !isSyncing {
ForEach(Array(lastFullSyncSummary.errors.enumerated()), id: \.offset) { _, message in
Text(message)
.font(.caption2)
.foregroundStyle(.red)
}
}
}
}
Divider()
.frame(maxWidth: 480)
pendingOperationsRow
.frame(maxWidth: 480, alignment: .leading)
}
}
private var pendingOperationsRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Pending Sync")
.font(.subheadline.weight(.medium))
Spacer()
if isSyncing {
ProgressView().controlSize(.small)
} else {
Button("Retry") { Task { await retrySync() } }
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(Color.accentColor)
.disabled(pendingOperations.isEmpty)
}
}
if pendingOperations.isEmpty {
Text("Everything's synced.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(pendingOperations) { operation in
pendingOperationRow(operation)
}
}
}
}
}
private func pendingOperationRow(_ operation: PendingOperationSummary) -> some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: operation.lastError == nil ? "clock" : "exclamationmark.triangle.fill")
.foregroundStyle(operation.lastError == nil ? Color.secondary : Color.orange)
.font(.caption)
VStack(alignment: .leading, spacing: 2) {
Text(operationLabel(operation.kind))
.font(.caption)
if let lastError = operation.lastError {
Text(lastError)
.font(.caption2)
.foregroundStyle(.red)
}
}
}
}
private func operationLabel(_ kind: String) -> String {
switch kind {
case "updateDocument": return "Document edit"
case "updateCollection": return "Collection rename"
case "createPin": return "Pin"
case "deletePin": return "Unpin"
case "createSubscription": return "Subscribe"
case "deleteSubscription": return "Unsubscribe"
case "starDocument", "starCollection": return "Star"
case "deleteStar": return "Unstar"
default: return kind
}
}
// MARK: - Advanced
/// Everything here either does something destructive (clearing the
/// cache Full Local Sync just spent minutes building) or doesn't exist
/// yet gating all of it behind an off-by-default master toggle plus a
/// confirmation to turn that toggle on is the safeguard: nothing here
/// can be reached by accident, and nothing outside this section can
/// touch the cache at all, so there's no path to "messed up Full Local
/// Sync" that doesn't go through this screen on purpose.
private var advancedDetail: some View {
VStack(alignment: .leading, spacing: 20) {
sectionHeader
VStack(alignment: .leading, spacing: 6) {
Toggle("Enable Advanced Options", isOn: advancedOptionsBinding)
Text("Off by default on purpose. Turning this on unlocks things that can cause unintended behavior, including permanently losing your local cache.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 12) {
comingSoonRow("Export All Data")
comingSoonRow("Developer Diagnostics")
comingSoonRow("Reset Local Database")
}
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Text("Clear All Cache")
Spacer()
if didClearCache {
Label("Cleared", systemImage: "checkmark")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(role: .destructive) {
Task { await clearCache() }
} label: {
if isClearingCache {
ProgressView().controlSize(.small)
} else {
Text("Clear")
}
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
if let storageSummary {
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.")
.font(.caption2)
.foregroundStyle(.secondary)
}
.disabled(!isAdvancedOptionsEnabled || isClearingCache)
.opacity(isAdvancedOptionsEnabled ? 1 : 0.4)
.frame(maxWidth: 480, alignment: .leading)
}
.confirmationDialog(
"Enable Advanced Options?",
isPresented: $isShowingAdvancedWarning,
titleVisibility: .visible
) {
Button("Enable", role: .destructive) { isAdvancedOptionsEnabled = true }
Button("Cancel", role: .cancel) {}
} message: {
Text("These settings can cause unintended behavior, including permanently losing your local cache. Only continue if you know what you're doing.")
}
}
/// Never writes `true` directly turning the toggle on only opens the
/// warning dialog; only that dialog's own "Enable" button actually sets
/// it. Turning off doesn't need confirmation.
private var advancedOptionsBinding: Binding<Bool> {
Binding(
get: { isAdvancedOptionsEnabled },
set: { newValue in
if newValue {
isShowingAdvancedWarning = true
} else {
isAdvancedOptionsEnabled = false
}
}
)
}
private func comingSoonRow(_ title: String) -> some View {
HStack {
Text(title)
Spacer()
Text("Coming Soon")
.font(.caption)
.foregroundStyle(.secondary)
}
.disabled(true)
.opacity(0.5)
}
// MARK: - About
private var aboutDetail: some View {
VStack(spacing: 16) {
sectionHeader
AboutInfoView()
.frame(maxWidth: 420)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
// MARK: - Helpers
private func labeledRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.foregroundStyle(.secondary)
Spacer()
Text(value)
}
.font(.callout)
}
private func formattedBytes(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
private func fullSyncSummaryText(_ summary: FullSyncSummary) -> String {
if summary.errors.isEmpty {
return "Synced \(summary.documentsCount) documents across \(summary.collectionsCount) collections."
}
return "Synced with \(summary.errors.count) error\(summary.errors.count == 1 ? "" : "s")."
}
private func refreshSyncState() async {
guard let cachingClient = session.cachingClient else { return }
storageSummary = await cachingClient.cacheStorageSummary()
pendingOperations = await cachingClient.pendingOperations()
}
private func runFullSync() async {
guard let cachingClient = session.cachingClient, !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
lastFullSyncSummary = await cachingClient.performFullSync()
await refreshSyncState()
}
private func retrySync() async {
guard let cachingClient = session.cachingClient, !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
lastFlushSummary = await cachingClient.flushPendingOperations()
await refreshSyncState()
}
private func clearCache() async {
guard let cachingClient = session.cachingClient else { return }
isClearingCache = true
defer { isClearingCache = false }
await cachingClient.clearCache()
await refreshSyncState()
didClearCache = true
Task {
try? await Task.sleep(for: .seconds(2))
didClearCache = false
}
}
}
#endif
@@ -9,14 +9,27 @@ import OutlineKit
/// client-side rather than `collections.documents`. /// client-side rather than `collections.documents`.
struct CollectionDocumentsOutline: View { struct CollectionDocumentsOutline: View {
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel @State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption let sortOption: SidebarSortOption
let refreshToken: Int let refreshToken: Int
/// Bumped from `ContentView_macOS` whenever a document is created from
/// somewhere that has no direct handle on this row the reader's
/// toolbar "New Document" button and Home's, specifically. Those can't
/// call `onDocumentsChanged()` the way a same-row sheet does, since they
/// don't know which (if any) sidebar row corresponds to where the new
/// document landed, so every expanded row just reloads itself.
let externalRefreshToken: Int
let selectedDocumentID: String? let selectedDocumentID: String?
/// Full chain from root to the clicked document (inclusive) lets the /// Full chain from root to the clicked document (inclusive) lets the
/// toolbar render the real hierarchy instead of just the leaf title. /// toolbar render the real hierarchy instead of just the leaf title.
let onSelectDocument: ([OutlineDocument]) -> Void let onSelectDocument: ([OutlineDocument]) -> Void
/// Loaded once per collection (`pins.list` is collection-scoped) rather
/// than per-row a per-row `pins.list`/lookup would be an N+1 call for
/// every document in the tree.
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
private var tree: [DocumentNode] { private var tree: [DocumentNode] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
} }
@@ -26,13 +39,16 @@ struct CollectionDocumentsOutline: View {
collection: OutlineCollection, collection: OutlineCollection,
sortOption: SidebarSortOption, sortOption: SidebarSortOption,
refreshToken: Int, refreshToken: Int,
externalRefreshToken: Int,
selectedDocumentID: String?, selectedDocumentID: String?,
onSelectDocument: @escaping ([OutlineDocument]) -> Void onSelectDocument: @escaping ([OutlineDocument]) -> Void
) { ) {
self.apiClient = apiClient self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption self.sortOption = sortOption
self.refreshToken = refreshToken self.refreshToken = refreshToken
self.externalRefreshToken = externalRefreshToken
self.selectedDocumentID = selectedDocumentID self.selectedDocumentID = selectedDocumentID
self.onSelectDocument = onSelectDocument self.onSelectDocument = onSelectDocument
} }
@@ -56,13 +72,26 @@ struct CollectionDocumentsOutline: View {
depth: 0, depth: 0,
ancestors: [], ancestors: [],
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument, onSelectDocument: onSelectDocument,
onDocumentsChanged: { await viewModel.load() } onDocumentsChanged: { await viewModel.load() },
onPinsChanged: { await loadPins() }
) )
} }
} }
} }
.task(id: refreshToken) { await viewModel.load() } // Combined into one identity rather than two separate `.task(id:)`
// modifiers each of those fires once unconditionally on first
// appear, so two of them would double the initial load.
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
await viewModel.load()
await loadPins()
}
}
private func loadPins() async {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return }
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
} }
} }
@@ -81,8 +110,10 @@ private struct DocumentNodeRow: View {
/// Chain from root down to (not including) this node. /// Chain from root down to (not including) this node.
let ancestors: [OutlineDocument] let ancestors: [OutlineDocument]
let selectedDocumentID: String? let selectedDocumentID: String?
let pinsByDocumentID: [String: OutlinePin]
let onSelectDocument: ([OutlineDocument]) -> Void let onSelectDocument: ([OutlineDocument]) -> Void
let onDocumentsChanged: () async -> Void let onDocumentsChanged: () async -> Void
let onPinsChanged: () async -> Void
@State private var isExpanded = false @State private var isExpanded = false
@@ -96,6 +127,7 @@ private struct DocumentNodeRow: View {
@State private var isShowingInsightsSheet = false @State private var isShowingInsightsSheet = false
@State private var isShowingPresentSheet = false @State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false @State private var isShowingSearchSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String? @State private var actionErrorMessage: String?
private var isSelected: Bool { private var isSelected: Bool {
@@ -172,8 +204,10 @@ private struct DocumentNodeRow: View {
depth: depth + 1, depth: depth + 1,
ancestors: ancestors + [node.document], ancestors: ancestors + [node.document],
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument, onSelectDocument: onSelectDocument,
onDocumentsChanged: onDocumentsChanged onDocumentsChanged: onDocumentsChanged,
onPinsChanged: onPinsChanged
) )
} }
} }
@@ -252,6 +286,11 @@ private struct DocumentNodeRow: View {
.sheet(isPresented: $isShowingSearchSheet) { .sheet(isPresented: $isShowingSearchSheet) {
DocumentSearchSheet(apiClient: apiClient, document: node.document) DocumentSearchSheet(apiClient: apiClient, document: node.document)
} }
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialParentDocument: node.document) { _ in
Task { await onDocumentsChanged() }
}
}
} }
@ViewBuilder @ViewBuilder
@@ -259,7 +298,11 @@ private struct DocumentNodeRow: View {
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") { Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
Task { await star() } Task { await star() }
} }
// No `subscriptions.*` endpoint in the API nothing to back this with. // subscriptions.* does exist and works (confirmed against a live
// server via the reader's menu) not shown here because
// subscriptions.list is per-document, so reflecting accurate
// per-row state for every document in the tree would mean an N+1
// call storm. Use the reader's menu instead.
Button("Unsubscribe") {} Button("Unsubscribe") {}
.disabled(true) .disabled(true)
@@ -273,8 +316,9 @@ private struct DocumentNodeRow: View {
renameText = node.document.title renameText = node.document.title
isShowingRenameAlert = true isShowingRenameAlert = true
} }
// Sharing/membership management is its own subsystem, not a one-off // DocumentShareSheet now has a real "People with access" section
// action deferred rather than half-built here. // this row just doesn't have a sheet wired up to present it yet
// (only the reader toolbar does). Use the reader's menu instead.
Button("Permissions…") {} Button("Permissions…") {}
.disabled(true) .disabled(true)
@@ -303,11 +347,13 @@ private struct DocumentNodeRow: View {
Button("Import Document…") {} Button("Import Document…") {}
.disabled(true) .disabled(true)
Button("New Document") { Button("New Document") {
Task { await createChildDocument() } isShowingNewDocumentSheet = true
}
// Scoped to this collection (`collection.id`) "Pin to Collection",
// distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
Task { await togglePin() }
} }
// No `pins.*` endpoint in the API nothing to back this with.
Button("Pin") {}
.disabled(true)
Divider() Divider()
@@ -351,6 +397,19 @@ private struct DocumentNodeRow: View {
} }
} }
private func togglePin() async {
do {
if let pin = pinsByDocumentID[node.document.id] {
try await apiClient.deletePin(id: pin.id)
} else {
_ = try await apiClient.createPin(CreatePinRequest(documentId: node.document.id, collectionId: node.document.collectionId))
}
await onPinsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.")
}
}
private func rename() async { private func rename() async {
do { do {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText)) _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
@@ -395,26 +454,6 @@ private struct DocumentNodeRow: View {
} }
} }
private func createChildDocument() async {
guard let collectionId = node.document.collectionId else {
actionErrorMessage = "This document isn't in a collection."
return
}
do {
_ = try await apiClient.createDocument(
CreateDocumentRequest(
title: "Untitled",
text: "",
collectionId: collectionId,
parentDocumentId: node.document.id
)
)
await onDocumentsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
}
}
private func delete() async { private func delete() async {
do { do {
try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id)) try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id))
@@ -10,7 +10,7 @@ struct CollectionRowView: View {
} icon: { } icon: {
if let emoji = collection.emojiIcon { if let emoji = collection.emojiIcon {
Text(emoji) 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) Image(systemName: symbolName)
.foregroundStyle(tintColor) .foregroundStyle(tintColor)
} else { } else {
@@ -11,6 +11,8 @@ struct CollectionTreeRow: View {
let isExpanded: Bool let isExpanded: Bool
let isSelected: Bool let isSelected: Bool
let selectedDocumentID: String? let selectedDocumentID: String?
/// See the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let onToggle: () -> Void let onToggle: () -> Void
let onSelectDocument: ([OutlineDocument]) -> Void let onSelectDocument: ([OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void let onSearchInCollection: (OutlineCollection) -> Void
@@ -22,6 +24,7 @@ struct CollectionTreeRow: View {
@State private var isShowingRenameAlert = false @State private var isShowingRenameAlert = false
@State private var renameText = "" @State private var renameText = ""
@State private var isShowingDeleteConfirmation = false @State private var isShowingDeleteConfirmation = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String? @State private var actionErrorMessage: String?
var body: some View { var body: some View {
@@ -62,6 +65,7 @@ struct CollectionTreeRow: View {
collection: collection, collection: collection,
sortOption: sortOption, sortOption: sortOption,
refreshToken: documentsRefreshToken, refreshToken: documentsRefreshToken,
externalRefreshToken: externalRefreshToken,
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument onSelectDocument: onSelectDocument
) )
@@ -92,6 +96,12 @@ struct CollectionTreeRow: View {
} message: { } message: {
Text(actionErrorMessage ?? "") Text(actionErrorMessage ?? "")
} }
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in
documentsRefreshToken += 1
Task { await onCollectionsChanged() }
}
}
} }
@ViewBuilder @ViewBuilder
@@ -103,7 +113,7 @@ struct CollectionTreeRow: View {
Divider() Divider()
Button("New Document") { Button("New Document") {
Task { await createDocument() } isShowingNewDocumentSheet = true
} }
Divider() Divider()
@@ -148,18 +158,6 @@ struct CollectionTreeRow: View {
} }
} }
private func createDocument() async {
do {
_ = try await apiClient.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collection.id)
)
documentsRefreshToken += 1
await onCollectionsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
}
}
private func rename() async { private func rename() async {
do { do {
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText)) _ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
@@ -7,6 +7,12 @@ struct CollectionsTreeView: View {
@Binding private var selectedCollection: OutlineCollection? @Binding private var selectedCollection: OutlineCollection?
let selectedDocumentID: String? let selectedDocumentID: String?
@State private var expandedCollectionIDs: Set<String> = [] @State private var expandedCollectionIDs: Set<String> = []
/// Bumped from `ContentView_macOS` whenever a document is created
/// somewhere with no direct handle on the sidebar row it belongs
/// under see the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let isShowingHome: Bool
let onSelectHome: () -> Void
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void let onSearchInCollection: (OutlineCollection) -> Void
@@ -14,12 +20,18 @@ struct CollectionsTreeView: View {
apiClient: OutlineAPIClient, apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>, selectedCollection: Binding<OutlineCollection?>,
selectedDocumentID: String?, selectedDocumentID: String?,
externalRefreshToken: Int,
isShowingHome: Bool,
onSelectHome: @escaping () -> Void,
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void, onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
onSearchInCollection: @escaping (OutlineCollection) -> Void onSearchInCollection: @escaping (OutlineCollection) -> Void
) { ) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient)) _viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
_selectedCollection = selectedCollection _selectedCollection = selectedCollection
self.selectedDocumentID = selectedDocumentID self.selectedDocumentID = selectedDocumentID
self.externalRefreshToken = externalRefreshToken
self.isShowingHome = isShowingHome
self.onSelectHome = onSelectHome
self.onSelectDocument = onSelectDocument self.onSelectDocument = onSelectDocument
self.onSearchInCollection = onSearchInCollection self.onSearchInCollection = onSearchInCollection
} }
@@ -31,6 +43,7 @@ struct CollectionsTreeView: View {
Task { await viewModel.load() } Task { await viewModel.load() }
} }
} }
homeRow
content content
} }
.task { .task {
@@ -42,6 +55,32 @@ struct CollectionsTreeView: View {
} }
} }
/// Pinned above the collections list, not inside the scroll region
/// Home isn't a collection, so it doesn't belong in `viewModel.collections`
/// or compete with them for scroll space.
private var homeRow: some View {
Button(action: onSelectHome) {
HStack(spacing: 6) {
Image(systemName: "house.fill")
.font(.callout)
.foregroundStyle(.secondary)
Text("Home")
.font(.body)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.contentShape(Rectangle())
.background(
isShowingHome ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
}
.buttonStyle(.plain)
.padding(.horizontal, 8)
.padding(.top, 4)
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
Group { Group {
@@ -81,6 +120,7 @@ struct CollectionsTreeView: View {
isExpanded: expandedCollectionIDs.contains(collection.id), isExpanded: expandedCollectionIDs.contains(collection.id),
isSelected: selectedCollection?.id == collection.id, isSelected: selectedCollection?.id == collection.id,
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
externalRefreshToken: externalRefreshToken,
onToggle: { toggle(collection) }, onToggle: { toggle(collection) },
onSelectDocument: { chain in onSelectDocument(collection, chain) }, onSelectDocument: { chain in onSelectDocument(collection, chain) },
onSearchInCollection: onSearchInCollection, onSearchInCollection: onSearchInCollection,
@@ -95,13 +135,6 @@ struct CollectionsTreeView: View {
} }
.task { .task {
await viewModel.load() await viewModel.load()
// Stand-in for Outline's own configured "Start view" we don't have
// a confirmed schema for `team.preferences` to read the actual
// setting, so this defaults to the first collection instead of
// landing on an empty "No Collection Selected" placeholder.
if selectedCollection == nil, let first = viewModel.collections.first {
selectedCollection = first
}
} }
// A document opened from outside the sidebar (detail pane's list, // A document opened from outside the sidebar (detail pane's list,
// global search) wouldn't otherwise expand its collection here, so // global search) wouldn't otherwise expand its collection here, so
@@ -4,6 +4,12 @@ import OutlineKit
struct ContentView_macOS: View { struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = 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.
@State private var isShowingHome = true
@State private var selectedCollection: OutlineCollection? @State private var selectedCollection: OutlineCollection?
/// The real navigation stack, root to leaf also the source of truth for /// The real navigation stack, root to leaf also the source of truth for
/// the toolbar breadcrumb, so the two can't drift out of sync. /// the toolbar breadcrumb, so the two can't drift out of sync.
@@ -12,16 +18,83 @@ struct ContentView_macOS: View {
@State private var contextualSearchQuery = "" @State private var contextualSearchQuery = ""
@State private var isContextualSearchExpanded = false @State private var isContextualSearchExpanded = false
@FocusState private var isContextualSearchFocused: Bool @FocusState private var isContextualSearchFocused: Bool
/// Bumped whenever a document is created from somewhere with no direct
/// handle on the sidebar row it belongs under (the reader toolbar's and
/// Home's "New Document" buttons) every expanded sidebar row reloads
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
@State private var documentsChangedToken = 0
private var trimmedGlobalQuery: String { private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) 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 { 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 { NavigationSplitView {
VStack(spacing: 0) { VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery) SidebarSearchField(text: $globalSearchQuery)
Divider() Divider()
if isOfflineModeEnabled {
OfflineBanner(isManual: true)
Divider()
} else if !session.networkMonitor.isOnline {
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
Divider()
}
sidebar sidebar
AccountFooter() AccountFooter()
} }
@@ -43,18 +116,49 @@ struct ContentView_macOS: View {
// mutually exclusive: whenever a document's pushed (back button // mutually exclusive: whenever a document's pushed (back button
// visible), this shows the document hierarchy instead of falling // visible), this shows the document hierarchy instead of falling
// back to the workspace badge. // back to the workspace badge.
ToolbarItem(placement: .navigation) {
Button {
goHome()
} label: {
Image(systemName: "house")
}
.help("Home")
}
ToolbarItem(placement: .navigation) { ToolbarItem(placement: .navigation) {
leadingToolbarContent leadingToolbarContent
} }
// Custom instead of `.searchable`: that modifier always renders a // Custom instead of `.searchable`: that modifier always renders a
// full-width field, but this is meant to sit alongside the other // full-width field, but this is meant to sit alongside the other
// per-document toolbar buttons as a plain icon that only expands // per-document toolbar buttons as a plain icon that only expands
// into a field once clicked. // into a field once clicked. Hidden on the Home landing page
// itself `contextualSearchQuery` is only ever read by
// `CollectionOverviewView`, so on Home it was a dead end: a
// user could click it, type, and nothing would happen. The
// sidebar's global search already covers "search everything."
if !(isShowingHome && documentPath.isEmpty) {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
contextualSearchField contextualSearchField
} }
} }
} }
// Any explicit collection pick sidebar click, "Search in
// Collection" means the user has navigated away from Home.
.onChange(of: selectedCollection) { _, newValue in
if newValue != nil {
isShowingHome = false
}
}
}
private func goHome() {
globalSearchQuery = ""
contextualSearchQuery = ""
isContextualSearchExpanded = false
selectedCollection = nil
isShowingHome = true
navigation.isShowingSettings = false
replaceDocumentPath(with: [])
}
@ViewBuilder @ViewBuilder
private var contextualSearchField: some View { private var contextualSearchField: some View {
@@ -106,12 +210,19 @@ struct ContentView_macOS: View {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
Text("Search") Text("Search")
} }
} else if !documentPath.isEmpty, let selectedCollection { } else if !documentPath.isEmpty {
// Collection every ancestor (icon only) current document // Origin (collection, or Home if opened from there) every
// (icon + full title) ancestors stay icon-only so a deep // ancestor (icon only) current document (icon + full
// chain doesn't blow out the toolbar width. // title) ancestors stay icon-only so a deep chain doesn't
// blow out the toolbar width. Checked before `isShowingHome`
// since opening a document from Home still leaves that flag
// set the pushed document should win either way.
HStack(spacing: 6) { HStack(spacing: 6) {
if let selectedCollection {
CollectionRowView(collection: selectedCollection) CollectionRowView(collection: selectedCollection)
} else {
Image(systemName: "house.fill")
}
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
@@ -130,6 +241,11 @@ struct ContentView_macOS: View {
} }
} }
} }
} else if isShowingHome {
HStack(spacing: 6) {
Image(systemName: "house.fill")
Text("Home")
}
} else if let selectedCollection { } else if let selectedCollection {
CollectionRowView(collection: selectedCollection) CollectionRowView(collection: selectedCollection)
} else { } else {
@@ -153,6 +269,9 @@ struct ContentView_macOS: View {
apiClient: apiClient, apiClient: apiClient,
selectedCollection: $selectedCollection, selectedCollection: $selectedCollection,
selectedDocumentID: documentPath.last?.id, selectedDocumentID: documentPath.last?.id,
externalRefreshToken: documentsChangedToken,
isShowingHome: isShowingHome,
onSelectHome: goHome,
onSelectDocument: selectDocumentChain, onSelectDocument: selectDocumentChain,
onSearchInCollection: searchInCollection onSearchInCollection: searchInCollection
) )
@@ -205,6 +324,8 @@ struct ContentView_macOS: View {
Group { Group {
if !trimmedGlobalQuery.isEmpty { if !trimmedGlobalQuery.isEmpty {
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument) GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
} else if isShowingHome {
HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 })
} else if let selectedCollection { } else if let selectedCollection {
CollectionOverviewView( CollectionOverviewView(
apiClient: apiClient, apiClient: apiClient,
@@ -229,11 +350,12 @@ struct ContentView_macOS: View {
if !documentPath.isEmpty { if !documentPath.isEmpty {
documentPath.removeLast() documentPath.removeLast()
} }
} },
onDocumentCreated: { documentsChangedToken += 1 }
) )
} }
} }
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search") .id(trimmedGlobalQuery.isEmpty ? (isShowingHome ? "home" : (selectedCollection?.id ?? "none")) : "search")
} else { } else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark") ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
} }
@@ -12,6 +12,7 @@ import OutlineKit
struct DocumentReaderView: View { struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore @Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var viewModel: DocumentReaderViewModel @State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
@@ -23,6 +24,11 @@ struct DocumentReaderView: View {
/// longer visible in the collection it was opened from, so the reader /// longer visible in the collection it was opened from, so the reader
/// pops itself off the navigation stack. /// pops itself off the navigation stack.
let onDeleted: () -> Void let onDeleted: () -> Void
/// The reader's own "New Document" toolbar button has no direct handle
/// on the sidebar row it belongs under this tells the sidebar a
/// document exists now so it can pick it up. See
/// `CollectionDocumentsOutline.externalRefreshToken`.
let onDocumentCreated: () -> Void
@State private var isShowingUnpublishConfirmation = false @State private var isShowingUnpublishConfirmation = false
@State private var isShowingArchiveConfirmation = false @State private var isShowingArchiveConfirmation = false
@@ -33,19 +39,31 @@ struct DocumentReaderView: View {
@State private var isShowingPresentSheet = false @State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false @State private var isShowingSearchSheet = false
@State private var isShowingShareSheet = false @State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String? @State private var actionErrorMessage: String?
init( init(
apiClient: OutlineAPIClient, apiClient: OutlineAPIClient,
document: OutlineDocument, document: OutlineDocument,
onOpenChild: @escaping (OutlineDocument) -> Void, onOpenChild: @escaping (OutlineDocument) -> Void,
onDeleted: @escaping () -> Void onDeleted: @escaping () -> Void,
onDocumentCreated: @escaping () -> Void
) { ) {
self.apiClient = apiClient self.apiClient = apiClient
self.document = document self.document = document
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
self.onOpenChild = onOpenChild self.onOpenChild = onOpenChild
self.onDeleted = onDeleted 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
} }
var body: some View { var body: some View {
@@ -74,6 +92,7 @@ struct DocumentReaderView: View {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $viewModel.text, text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEditing isEditable: viewModel.isEditing
) )
@@ -102,7 +121,11 @@ struct DocumentReaderView: View {
} label: { } label: {
Image(systemName: "square.and.arrow.up") 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 { Button {
Task { await viewModel.toggleEditing() } Task { await viewModel.toggleEditing() }
@@ -116,7 +139,7 @@ struct DocumentReaderView: View {
.disabled(viewModel.isSaving) .disabled(viewModel.isSaving)
Button { Button {
Task { await createChildDocument() } isShowingNewDocumentSheet = true
} label: { } label: {
Image(systemName: "doc.badge.plus") Image(systemName: "doc.badge.plus")
} }
@@ -127,12 +150,22 @@ struct DocumentReaderView: View {
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis.circle")
} }
// SwiftUI's macOS `Menu` doesn't reliably re-evaluate a
// `Toggle`'s checkmark against updated @Observable state on
// its own without a fresh `.id()` per state combination,
// toggling Subscribed/Viewer Insights/Full Width kept
// showing the pre-toggle checkmark until the whole view was
// torn down and rebuilt (e.g. navigating away and back).
.id(menuIdentity)
} }
} }
.task { await viewModel.loadFullContent() } .task { await viewModel.loadFullContent() }
.task { .task {
await viewModel.loadPinAndSubscriptionState() await viewModel.loadPinAndSubscriptionState()
} }
.task {
await viewModel.loadInsightsEnabledState()
}
.task { .task {
while !Task.isCancelled { while !Task.isCancelled {
await viewModel.loadViewers() await viewModel.loadViewers()
@@ -197,14 +230,34 @@ struct DocumentReaderView: View {
.sheet(isPresented: $isShowingSearchSheet) { .sheet(isPresented: $isShowingSearchSheet) {
DocumentSearchSheet(apiClient: apiClient, document: document) DocumentSearchSheet(apiClient: apiClient, document: document)
} }
.sheet(isPresented: $isShowingShareSheet) { .sheet(isPresented: $isShowingNewDocumentSheet) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in
onDocumentCreated()
onOpenChild(child)
} }
} }
}
/// Every toggle-backed piece of state shown as a checkmark inside
/// `menuContent` see the `.id()` comment on the `Menu` above.
private var menuIdentity: String {
[
starStore.isStarred(documentId: viewModel.documentId),
viewModel.isSubscribed,
viewModel.isPinned,
viewModel.isInsightsEnabled ?? false,
viewModel.isFullWidth,
viewModel.isEditing,
isEffectivelyOnline
].map(String.init).joined(separator: "-")
}
@ViewBuilder @ViewBuilder
private var viewerAvatars: some View { private var viewerAvatars: some View {
if !viewModel.viewers.isEmpty { // Tied to the Viewer Insights toggle that's the feature this data
// belongs to, so turning it off should hide the avatars immediately
// rather than leaving them showing until the view reloads.
if viewModel.isInsightsEnabled == true, !viewModel.viewers.isEmpty {
HStack(spacing: -6) { HStack(spacing: -6) {
ForEach(viewModel.viewers.prefix(5)) { viewer in ForEach(viewModel.viewers.prefix(5)) { viewer in
AvatarBadge( AvatarBadge(
@@ -249,48 +302,57 @@ struct DocumentReaderView: View {
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") { Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
Task { await star() } Task { await star() }
} }
Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") { Toggle("Subscribed", isOn: Binding(
Task { await toggleSubscription() } get: { viewModel.isSubscribed },
} set: { _ in Task { await toggleSubscription() } }
))
Divider() Divider()
Button(viewModel.isEditing ? "Done Editing" : "Edit") { Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() } Task { await viewModel.toggleEditing() }
} }
// Sharing/membership management is its own subsystem, not a one-off // Membership management now lives in DocumentShareSheet's "People
// action deferred rather than half-built here. // with access" section, alongside the share link same sheet,
Button("Permissions…") {} // same isShowingShareSheet state.
.disabled(true) Button("Permissions…") {
isShowingShareSheet = true
}
.disabled(!isEffectivelyOnline)
Divider() Divider()
Button("Templatize") { Button("Templatize") {
Task { await templatize() } Task { await templatize() }
} }
.disabled(!isEffectivelyOnline)
Button("Duplicate") { Button("Duplicate") {
Task { await duplicate() } Task { await duplicate() }
} }
.disabled(!isEffectivelyOnline)
Button("Unpublish") { Button("Unpublish") {
isShowingUnpublishConfirmation = true isShowingUnpublishConfirmation = true
} }
.disabled(!isEffectivelyOnline)
Button("Archive…") { Button("Archive…") {
isShowingArchiveConfirmation = true isShowingArchiveConfirmation = true
} }
.disabled(!isEffectivelyOnline)
Divider() Divider()
Button("Move") { Button("Move") {
isShowingMoveSheet = true isShowingMoveSheet = true
} }
.disabled(!isEffectivelyOnline)
// Multipart file upload is its own subsystem deferred rather than // Multipart file upload is its own subsystem deferred rather than
// half-built here. // half-built here.
Button("Import Document…") {} Button("Import Document…") {}
.disabled(true) .disabled(true)
Button("New Document") { Button("New Document") {
Task { await createChildDocument() } isShowingNewDocumentSheet = true
} }
Button(viewModel.isPinned ? "Unpin" : "Pin") { Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
Task { await togglePin() } Task { await togglePin() }
} }
@@ -299,9 +361,13 @@ struct DocumentReaderView: View {
Button("History") { Button("History") {
isShowingHistorySheet = true isShowingHistorySheet = true
} }
.disabled(!isEffectivelyOnline)
Button("Insights") { Button("Insights") {
isShowingInsightsSheet = true 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") { Button("Present") {
isShowingPresentSheet = true isShowingPresentSheet = true
} }
@@ -311,6 +377,7 @@ struct DocumentReaderView: View {
Button("Download") { Button("Download") {
Task { await download() } Task { await download() }
} }
.disabled(!isEffectivelyOnline)
Button("Copy") { Button("Copy") {
Task { await copyMarkdown() } Task { await copyMarkdown() }
} }
@@ -323,21 +390,28 @@ struct DocumentReaderView: View {
Divider() Divider()
Button("Enable Viewer Insights") { Toggle("Viewer Insights", isOn: Binding(
Task { await enableInsights() } get: { viewModel.isInsightsEnabled ?? false },
} set: { _ in Task { await toggleInsights() } }
Button("Enable Embeds") { ))
Task { await enableEmbeds() } .disabled(!isEffectivelyOnline)
} // Confirmed against a live server: there's no per-document embeds
Button(viewModel.isFullWidth ? "Default Width" : "Full Width") { // field. Only a workspace-level setting exists, and that's not
Task { await toggleFullWidth() } // reachable via the API either (no `team.update` endpoint in the
} // vendored spec) disabled rather than kept as a broken action.
Button("Enable Embeds") {}
.disabled(true)
Toggle("Full Width", isOn: Binding(
get: { viewModel.isFullWidth },
set: { _ in Task { await toggleFullWidth() } }
))
Divider() Divider()
Button("Delete…", role: .destructive) { Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true isShowingDeleteConfirmation = true
} }
.disabled(!isEffectivelyOnline)
} }
private func star() async { private func star() async {
@@ -372,19 +446,11 @@ struct DocumentReaderView: View {
} }
} }
private func enableInsights() async { private func toggleInsights() async {
do { do {
try await viewModel.enableViewerInsights() try await viewModel.toggleViewerInsights()
} catch { } catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.") actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.")
}
}
private func enableEmbeds() async {
do {
try await viewModel.enableEmbeds()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.")
} }
} }
@@ -431,21 +497,6 @@ struct DocumentReaderView: View {
} }
} }
private func createChildDocument() async {
guard let collectionId = viewModel.collectionId else {
actionErrorMessage = "This document isn't in a collection."
return
}
do {
let child = try await apiClient.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId)
)
onOpenChild(child)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
}
}
private func download() async { private func download() async {
do { do {
let markdown = try await apiClient.exportDocument(id: viewModel.documentId) let markdown = try await apiClient.exportDocument(id: viewModel.documentId)
@@ -28,7 +28,12 @@ final class DocumentReaderViewModel {
private var pinId: String? private var pinId: String?
private(set) var isSubscribed = false private(set) var isSubscribed = false
private var subscriptionId: String? private var subscriptionId: String?
private(set) var share: OutlineShare?
/// `nil` until checked. Inferred from whether `documents.insights`
/// succeeds or fails `insightsEnabled` isn't readable back off
/// `Document` in the vendored spec, so there's no direct field to read.
/// This is a heuristic, not confirmed server behavior.
private(set) var isInsightsEnabled: Bool?
let documentId: String let documentId: String
private let apiClient: OutlineAPIClient private let apiClient: OutlineAPIClient
@@ -75,7 +80,9 @@ final class DocumentReaderViewModel {
} }
func loadPinAndSubscriptionState() async { func loadPinAndSubscriptionState() async {
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)), // `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)),
let match = pins.first(where: { $0.documentId == documentId }) { let match = pins.first(where: { $0.documentId == documentId }) {
isPinned = true isPinned = true
pinId = match.id pinId = match.id
@@ -94,8 +101,13 @@ final class DocumentReaderViewModel {
} }
} }
func loadShare() async { func loadInsightsEnabledState() async {
share = try? await apiClient.shareInfo(documentId: documentId) do {
_ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId))
isInsightsEnabled = true
} catch {
isInsightsEnabled = false
}
} }
func togglePin() async throws { func togglePin() async throws {
@@ -110,7 +122,7 @@ final class DocumentReaderViewModel {
throw error throw error
} }
} else { } else {
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId)) let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil))
pinId = pin.id pinId = pin.id
isPinned = true isPinned = true
} }
@@ -134,10 +146,6 @@ final class DocumentReaderViewModel {
} }
} }
func createOrLoadShare() async throws {
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
}
/// Turning editing off saves; turning it on is just a mode switch. /// Turning editing off saves; turning it on is just a mode switch.
func toggleEditing() async { func toggleEditing() async {
guard isEditing else { guard isEditing else {
@@ -168,14 +176,14 @@ final class DocumentReaderViewModel {
} }
} }
/// Fire-and-forget: `insightsEnabled` isn't readable back off `Document` func toggleViewerInsights() async throws {
/// in the vendored spec, so there's no state to reflect as a checkmark. let newValue = !(isInsightsEnabled ?? false)
func enableViewerInsights() async throws { isInsightsEnabled = newValue
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true)) do {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue))
} catch {
isInsightsEnabled = !newValue
throw error
} }
/// Fire-and-forget, speculative field see `UpdateDocumentRequest.documentEmbeds`.
func enableEmbeds() async throws {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true))
} }
} }
@@ -3,68 +3,306 @@ import AppKit
import SwiftUI import SwiftUI
import OutlineKit import OutlineKit
/// Content of the Share popover anchored to the reader toolbar's Share
/// button (see `DocumentReaderView`'s `.popover(isPresented:)`). Was a
/// modal `.sheet` originally moved to a popover so it reads as "options
/// for this button" instead of interrupting the whole window.
@MainActor @MainActor
struct DocumentShareSheet: View { struct DocumentShareSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let documentId: String let documentId: String
@State private var share: OutlineShare? @State private var share: OutlineShare?
@State private var isLoading = false @State private var titleOverride = ""
@State private var isUpdating = false @State private var isLoadingShare = false
@State private var errorMessage: String? @State private var isUpdatingShare = false
@State private var isRevoking = false
@State private var isShowingRevokeConfirmation = false
@State private var isShowingTitleField = false
@State private var shareErrorMessage: String?
@State private var didCopy = false @State private var didCopy = false
@State private var members: [OutlineDocumentMember] = []
@State private var isLoadingMembers = false
@State private var isShowingAddPerson = false
@State private var userSearchQuery = ""
@State private var userSearchResults: [OutlineUser] = []
@State private var isSearchingUsers = false
@State private var selectedPermission = "read"
@State private var isAddingUser = false
@State private var actionErrorMessage: String?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Share") Text("Share")
.font(.headline) .font(.headline)
Spacer() .padding(.horizontal, 16)
Button("Done") { dismiss() } .padding(.top, 14)
.padding(.bottom, 10)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
shareLinkSection
peopleSection
}
.padding(16)
}
.frame(minHeight: 150, maxHeight: 900)
}
.frame(width: 280)
.task { await loadShare() }
.task { await loadMembers() }
.task(id: userSearchQuery) {
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
await searchUsers(userSearchQuery)
}
.confirmationDialog(
"Revoke this share link?",
isPresented: $isShowingRevokeConfirmation,
titleVisibility: .visible
) {
Button("Revoke", role: .destructive) {
Task { await revoke() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Anyone using this link will no longer be able to access the document.")
}
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
Button("OK") { actionErrorMessage = nil }
} message: {
Text(actionErrorMessage ?? "")
}
} }
if isLoading { // MARK: - Link section
ProgressView().frame(maxWidth: .infinity)
} else if let errorMessage { @ViewBuilder
Text(errorMessage) private var shareLinkSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionHeader(icon: "link", title: "Public Link")
if isLoadingShare {
ProgressView()
.controlSize(.small)
.frame(maxWidth: .infinity, alignment: .center)
} else if let shareErrorMessage {
Text(shareErrorMessage)
.font(.callout) .font(.callout)
.foregroundStyle(.red) .foregroundStyle(.red)
} else if let share { } else if let share {
HStack { VStack(alignment: .leading, spacing: 6) {
Text(share.url) if let url = share.url {
HStack(spacing: 8) {
Image(systemName: "globe")
.foregroundStyle(.secondary)
.font(.callout)
Text(url)
.font(.callout) .font(.callout)
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .truncationMode(.middle)
Spacer() Spacer(minLength: 0)
Button { Button {
copyLink(share.url) copyLink(url)
} label: { } label: {
Image(systemName: didCopy ? "checkmark" : "doc.on.doc") Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
.font(.callout)
}
.buttonStyle(.plain)
.foregroundStyle(didCopy ? .green : .secondary)
.help("Copy link")
}
}
if isShowingTitleField {
TextField("Public page title", text: $titleOverride)
.textFieldStyle(.roundedBorder)
.font(.callout)
.disabled(isUpdatingShare)
.onSubmit {
Task { await setTitle(titleOverride) }
}
}
}
.padding(10)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: 12) {
Button(isShowingTitleField ? "Hide title field" : "Set public title") {
isShowingTitleField.toggle()
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Revoke", role: .destructive) {
isShowingRevokeConfirmation = true
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.red)
.disabled(isRevoking)
}
} else {
Button {
Task { await create() }
} label: {
Label("Create Share Link", systemImage: "link.badge.plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
}
}
// MARK: - People section
@ViewBuilder
private var peopleSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionHeader(icon: "person.2", title: "People with Access")
Spacer()
Button {
isShowingAddPerson.toggle()
} label: {
Image(systemName: isShowingAddPerson ? "xmark.circle.fill" : "person.badge.plus")
.font(.callout)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Add a person")
}
if isShowingAddPerson {
addPersonSection
}
if isLoadingMembers {
ProgressView()
.controlSize(.small)
.frame(maxWidth: .infinity, alignment: .center)
} else if members.isEmpty {
Text("No one else has explicit access yet.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
VStack(spacing: 2) {
ForEach(members) { member in
memberRow(member)
}
}
}
}
}
private func sectionHeader(icon: String, title: String) -> some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.secondary)
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
}
private var addPersonSection: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("Search people by name or email", text: $userSearchQuery)
.textFieldStyle(.roundedBorder)
Picker("Permission", selection: $selectedPermission) {
Text("Can view").tag("read")
Text("Can edit").tag("read_write")
}
.pickerStyle(.segmented)
.labelsHidden()
if isSearchingUsers {
ProgressView()
.controlSize(.small)
.frame(maxWidth: .infinity, alignment: .center)
} else if !userSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if userSearchResults.isEmpty {
Text("No matches.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
VStack(spacing: 2) {
ForEach(userSearchResults) { user in
Button {
Task { await addUser(user) }
} label: {
HStack(spacing: 8) {
avatar(for: user.name)
Text(user.name)
.font(.callout)
Spacer(minLength: 0)
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
.padding(.vertical, 4)
}
.buttonStyle(.plain)
.disabled(isAddingUser)
}
}
}
}
}
.padding(10)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
}
private func memberRow(_ member: OutlineDocumentMember) -> some View {
HStack(spacing: 8) {
avatar(for: member.name)
Text(member.name)
.font(.callout)
Spacer(minLength: 0)
if let permission = member.permission {
Text(permission == "read_write" ? "Can edit" : "Can view")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.fill.tertiary, in: Capsule())
}
Button {
Task { await removeUser(member) }
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(8) .padding(.vertical, 4)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) }
Toggle( private func avatar(for name: String) -> some View {
"Published — accessible without sign-in", Circle()
isOn: Binding( .fill(.fill.secondary)
get: { share.published }, .frame(width: 22, height: 22)
set: { newValue in Task { await setPublished(newValue) } } .overlay {
) Text(initials(for: name))
) .font(.system(size: 10, weight: .semibold))
.disabled(isUpdating) .foregroundStyle(.secondary)
} else {
Button("Create Share Link") {
Task { await create() }
} }
} }
}
.padding(20) private func initials(for name: String) -> String {
.frame(width: 380) let parts = name.split(separator: " ").prefix(2)
.task { await load() } let letters = parts.compactMap { $0.first }
return letters.isEmpty ? "?" : String(letters).uppercased()
} }
private func copyLink(_ url: String) { private func copyLink(_ url: String) {
@@ -78,34 +316,96 @@ struct DocumentShareSheet: View {
} }
} }
private func load() async { private func loadShare() async {
isLoading = true isLoadingShare = true
defer { isLoading = false } defer { isLoadingShare = false }
do { do {
share = try await apiClient.shareInfo(documentId: documentId) share = try await apiClient.shareInfo(documentId: documentId)
titleOverride = share?.title ?? ""
} catch { } catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.") shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
} }
} }
private func create() async { private func create() async {
isLoading = true isLoadingShare = true
defer { isLoading = false } defer { isLoadingShare = false }
do { do {
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
titleOverride = share?.title ?? ""
} catch { } catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
} }
} }
private func setPublished(_ published: Bool) async { private func setTitle(_ title: String) async {
guard let share else { return } guard let share else { return }
isUpdating = true let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
defer { isUpdating = false } guard trimmed != (share.title ?? "") else { return }
isUpdatingShare = true
defer { isUpdatingShare = false }
do { do {
self.share = try await apiClient.updateShare(UpdateShareRequest(id: share.id, published: published)) self.share = try await apiClient.updateShare(
UpdateShareRequest(id: share.id, published: share.published, title: trimmed)
)
} catch { } catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
}
}
private func revoke() async {
guard let share else { return }
isRevoking = true
defer { isRevoking = false }
do {
try await apiClient.revokeShare(id: share.id)
self.share = nil
titleOverride = ""
isShowingTitleField = false
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.")
}
}
private func loadMembers() async {
isLoadingMembers = true
defer { isLoadingMembers = false }
members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? []
}
private func searchUsers(_ query: String) async {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
userSearchResults = []
return
}
isSearchingUsers = true
defer { isSearchingUsers = false }
userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? []
}
private func addUser(_ user: OutlineUser) async {
isAddingUser = true
defer { isAddingUser = false }
do {
_ = try await apiClient.addDocumentUser(
AddDocumentUserRequest(id: documentId, userId: user.id, permission: selectedPermission)
)
userSearchQuery = ""
userSearchResults = []
isShowingAddPerson = false
await loadMembers()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add this person.")
}
}
private func removeUser(_ member: OutlineDocumentMember) async {
do {
try await apiClient.removeDocumentUser(RemoveDocumentUserRequest(id: documentId, userId: member.id))
await loadMembers()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this person.")
} }
} }
} }
@@ -0,0 +1,169 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// The one shared "create a document" dialog every "New Document" entry
/// point (sidebar collection, sidebar document, reader toolbar/menu, Home)
/// opens this instead of silently creating an "Untitled" document. Mirrors
/// `MoveDocumentSheet`'s collection+parent picker pattern.
@MainActor
struct NewDocumentSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
/// Pre-selected collection e.g. opened from a specific collection's
/// "New Document". `nil` when opened from Home, where nothing is
/// pre-selected and the user must choose.
let initialCollectionID: String?
/// Pre-selected parent e.g. opened from a document's "New Document",
/// which creates a child of that document.
let initialParentDocument: OutlineDocument?
let onCreated: (OutlineDocument) -> Void
@State private var title = ""
@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 isCreating = false
@State private var errorMessage: String?
@FocusState private var isTitleFocused: Bool
init(
apiClient: OutlineAPIClient,
initialCollectionID: String? = nil,
initialParentDocument: OutlineDocument? = nil,
onCreated: @escaping (OutlineDocument) -> Void
) {
self.apiClient = apiClient
self.initialCollectionID = initialCollectionID
self.initialParentDocument = initialParentDocument
self.onCreated = onCreated
}
/// `rootDocuments` is only root-level (mirroring `MoveDocumentSheet`'s
/// intentionally shallow picker) if the initial parent is nested
/// deeper than that, it wouldn't otherwise appear as a selectable option
/// even though it's already the selection.
private var parentOptions: [OutlineDocument] {
var options = rootDocuments
if let initialParentDocument,
initialParentDocument.collectionId == selectedCollectionID,
!options.contains(where: { $0.id == initialParentDocument.id }) {
options.insert(initialParentDocument, at: 0)
}
return options
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("New Document")
.font(.headline)
TextField("Title", text: $title)
.textFieldStyle(.roundedBorder)
.focused($isTitleFocused)
.onSubmit { Task { await create() } }
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(parentOptions) { 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("Create") {
Task { await create() }
}
.keyboardShortcut(.defaultAction)
.disabled(selectedCollectionID == nil || isCreating)
}
}
.padding(20)
.frame(width: 380)
.task {
await loadCollections()
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
selectedParentID = initialParentDocument?.id
isTitleFocused = true
}
.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 create() async {
guard let selectedCollectionID else { return }
isCreating = true
defer { isCreating = false }
do {
let document = try await apiClient.createDocument(
CreateDocumentRequest(
title: title.isEmpty ? "Untitled" : title,
text: "",
collectionId: selectedCollectionID,
parentDocumentId: selectedParentID
)
)
onCreated(document)
dismiss()
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create this document.")
}
}
}
#endif
@@ -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,43 @@
#if os(macOS)
import SwiftUI
import OutlineKit
struct DocumentCardView: View {
@Environment(StarStore.self) private var starStore
let document: OutlineDocument
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
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)
}
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.headline)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Text(document.updatedAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(14)
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 10))
}
}
#endif
+10
View File
@@ -0,0 +1,10 @@
import Foundation
enum HomeTab: String, CaseIterable, Identifiable {
case recentlyViewed = "Recently Viewed"
case popular = "Popular"
case recentlyUpdated = "Recently Updated"
case createdByMe = "Created by Me"
var id: String { rawValue }
}
+190
View File
@@ -0,0 +1,190 @@
#if os(macOS)
import SwiftUI
import OutlineKit
struct HomeView: View {
let apiClient: OutlineAPIClient
let onOpenDocument: (OutlineDocument) -> Void
/// Home has no sidebar row of its own to reload directly this tells
/// the sidebar a document exists now so it can pick it up. See
/// `CollectionDocumentsOutline.externalRefreshToken`.
let onDocumentCreated: () -> Void
@State private var viewModel: HomeViewModel
@State private var selectedTab: HomeTab = .recentlyViewed
@State private var isShowingNewDocumentSheet = false
private let pinnedGridColumns = [GridItem(.adaptive(minimum: 260), spacing: 8)]
private let tabGridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)]
init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void, onDocumentCreated: @escaping () -> Void) {
self.apiClient = apiClient
self.onOpenDocument = onOpenDocument
self.onDocumentCreated = onDocumentCreated
_viewModel = State(initialValue: HomeViewModel(apiClient: apiClient))
}
private var isShowingPinnedSection: Bool {
viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty
}
var body: some View {
VStack(spacing: 0) {
if viewModel.hasRemoteChanges {
RemoteChangesBanner {
Task {
await viewModel.loadPinned()
await viewModel.load(tab: selectedTab)
}
}
}
// The pinned section claims roughly the top half when it has
// anything to show (scrolling within itself if there are enough
// pinned documents to overflow that), and collapses away entirely
// when there's nothing pinned so the tabs get the full height.
GeometryReader { proxy in
VStack(spacing: 0) {
if isShowingPinnedSection {
pinnedSection
.frame(height: max(proxy.size.height / 2, 180))
Divider()
}
tabSection
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
isShowingNewDocumentSheet = true
} label: {
Image(systemName: "doc.badge.plus")
}
.help("New Document")
}
}
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient) { document in
onDocumentCreated()
onOpenDocument(document)
}
}
.task { await viewModel.loadPinned() }
.task(id: selectedTab) { await viewModel.load(tab: selectedTab) }
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(45))
guard !Task.isCancelled else { break }
await viewModel.checkForRemoteChanges(tab: selectedTab)
}
}
}
private var pinnedSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Pinned")
.font(.title3.weight(.semibold))
.padding(.horizontal, 24)
.padding(.top, 20)
if viewModel.isLoadingPinned {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVGrid(columns: pinnedGridColumns, spacing: 8) {
ForEach(viewModel.pinnedDocuments) { document in
Button {
onOpenDocument(document)
} label: {
PinnedDocumentCard(document: document)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
}
}
}
private var tabSection: some View {
VStack(alignment: .leading, spacing: 0) {
tabBar
Divider()
let documents = viewModel.documents(for: selectedTab)
Group {
if viewModel.isLoadingTab && documents.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = viewModel.errorMessage, documents.isEmpty {
ContentUnavailableView {
Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.load(tab: selectedTab) }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if documents.isEmpty {
ContentUnavailableView(
"No Documents",
systemImage: "doc.text",
description: Text("Nothing to show here yet.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVGrid(columns: tabGridColumns, spacing: 12) {
ForEach(documents) { document in
Button {
onOpenDocument(document)
} label: {
DocumentCardView(document: document)
}
.buttonStyle(.plain)
}
}
.padding(24)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// Mirrors `CollectionOverviewView.tabBar`'s exact style, rather than the
// native `.pickerStyle(.segmented)` this started with.
private var tabBar: some View {
HStack(spacing: 4) {
Spacer(minLength: 0)
ForEach(HomeTab.allCases) { tab in
Button {
selectedTab = tab
} label: {
Text(tab.rawValue)
.font(.callout.weight(selectedTab == tab ? .semibold : .regular))
.foregroundStyle(selectedTab == tab ? Color.primary : Color.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
}
.buttonStyle(.plain)
}
Spacer(minLength: 0)
}
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
}
}
#endif
+143
View File
@@ -0,0 +1,143 @@
import Foundation
import Observation
import OutlineKit
@MainActor
@Observable
final class HomeViewModel {
private(set) var pinnedDocuments: [OutlineDocument] = []
private(set) var recentlyViewed: [OutlineDocument] = []
private(set) var popular: [OutlineDocument] = []
private(set) var recentlyUpdated: [OutlineDocument] = []
private(set) var createdByMe: [OutlineDocument] = []
var isLoadingPinned = false
var isLoadingTab = false
var errorMessage: String?
/// Drives a "Refresh" banner rather than silently swapping content out
/// from under whoever's looking at it see `CollectionsViewModel`'s
/// identical pattern.
var hasRemoteChanges = false
private let apiClient: OutlineAPIClient
private var currentUserID: String?
init(apiClient: OutlineAPIClient) {
self.apiClient = apiClient
}
func documents(for tab: HomeTab) -> [OutlineDocument] {
switch tab {
case .recentlyViewed: recentlyViewed
case .popular: popular
case .recentlyUpdated: recentlyUpdated
case .createdByMe: createdByMe
}
}
func loadPinned() async {
isLoadingPinned = true
defer { isLoadingPinned = false }
pinnedDocuments = await fetchPinned()
}
func load(tab: HomeTab) async {
isLoadingTab = true
errorMessage = nil
defer { isLoadingTab = false }
do {
let documents = try await fetch(tab: tab)
set(documents, for: tab)
hasRemoteChanges = false
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load documents.")
}
}
/// 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 (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 freshPinnedTask = fetchPinnedThrowing()
guard let freshTab = try? await fetch(tab: tab) else { return }
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 fetchPinnedThrowing() async throws -> [OutlineDocument] {
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
var documents: [OutlineDocument] = []
for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
documents.append(document)
}
}
return documents
}
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
switch tab {
case .recentlyViewed:
return try await apiClient.listViewedDocuments(offset: 0, limit: 25)
case .popular:
// `sort: "viewCount"` was a guess and the server rejected it
// outright ("sort: Invalid input") sort is validated
// server-side against a fixed set, not free-form like the
// vendored spec's typing implies. Same conclusion as
// `CollectionTab.popular`: there's no real popularity
// ranking exposed via the REST API, so this falls back to
// the default list order rather than guessing again.
return try await apiClient.documentsList(DocumentsListRequest(limit: 25))
case .recentlyUpdated:
return try await apiClient.documentsList(
DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25)
)
case .createdByMe:
let userId = try await resolveCurrentUserID()
return try await apiClient.documentsList(
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
)
}
}
private func set(_ documents: [OutlineDocument], for tab: HomeTab) {
switch tab {
case .recentlyViewed: recentlyViewed = documents
case .popular: popular = documents
case .recentlyUpdated: recentlyUpdated = documents
case .createdByMe: createdByMe = documents
}
}
private func resolveCurrentUserID() async throws -> String {
if let currentUserID { return currentUserID }
let user = try await apiClient.currentUser()
currentUserID = user.id
return user.id
}
private static func fingerprint(_ documents: [OutlineDocument]) -> String {
documents
.map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" }
.sorted()
.joined(separator: "|")
}
}
@@ -0,0 +1,46 @@
#if os(macOS)
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.
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)
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.callout.weight(.medium))
.lineLimit(1)
Spacer(minLength: 0)
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
}
}
#endif
+12 -5
View File
@@ -14,6 +14,7 @@ import AppKit
@main @main
struct OutpostApp: App { struct OutpostApp: App {
@State private var session = SessionStore() @State private var session = SessionStore()
@State private var navigation = AppNavigation()
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
#if os(macOS) #if os(macOS)
@@ -25,6 +26,7 @@ struct OutpostApp: App {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(session) .environment(session)
.environment(navigation)
#if os(iOS) #if os(iOS)
.preferredColorScheme(appearance.colorScheme) .preferredColorScheme(appearance.colorScheme)
#endif #endif
@@ -41,6 +43,16 @@ struct OutpostApp: App {
openWindow(id: "about") openWindow(id: "about")
} }
} }
// 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) { CommandGroup(after: .appSettings) {
Divider() Divider()
Button("Log Out…") { Button("Log Out…") {
@@ -63,11 +75,6 @@ struct OutpostApp: App {
.disablesFullScreen() .disablesFullScreen()
} }
.windowResizability(.contentSize) .windowResizability(.contentSize)
Settings {
PreferencesView()
.environment(session)
}
#endif #endif
} }
+40
View File
@@ -0,0 +1,40 @@
import Observation
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case appearance, account, offlineSync, advanced, about
var id: String { rawValue }
var title: String {
switch self {
case .appearance: return "Appearance"
case .account: return "Account"
case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced"
case .about: return "About"
}
}
var icon: String {
switch self {
case .appearance: return "paintbrush"
case .account: return "person.crop.circle"
case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle"
}
}
}
/// 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
}
+55
View File
@@ -5,6 +5,19 @@ struct RootView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@State private var welcomeName: String? @State private var welcomeName: String?
@State private var starStore = StarStore() @State private var starStore = StarStore()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Real connectivity is only half of "can talk to the server" the
/// manual Offline Mode toggle is the other half. Syncing needs to
/// resume on either one clearing, not just a real reconnect: turning
/// the toggle off while the network had been up the whole time never
/// changes `networkMonitor.isOnline`, so keying the flush task off that
/// alone left pending operations stuck until the next real network
/// blip.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View { var body: some View {
ZStack { ZStack {
@@ -32,6 +45,48 @@ struct RootView: View {
starStore.reset() starStore.reset()
} }
} }
// Replay whatever queued up while offline the moment either signal
// clears a real reconnect, or the user turning Offline Mode back
// off no need to wait for the user to open Settings and hit Retry.
// Also catches Full Local Sync back up immediately, rather than
// leaving it to wait out the rest of the periodic loop below.
//
// The flush itself gets a retry loop with a cooloff, not just one
// attempt: a single transient failure (one bad operation, a blip
// mid-flush) used to leave everything else stuck until the user
// manually hit Retry or connectivity changed again. Keeps retrying
// every 5 minutes for as long as *anything* is still pending and
// this signal stays on stops on its own once the queue is empty,
// and `.task(id:)` cancels/restarts it automatically if
// isEffectivelyOnline flips again in the meantime.
.task(id: isEffectivelyOnline) {
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
if isFullLocalSyncEnabled {
_ = await cachingClient.performFullSync()
}
while !Task.isCancelled {
_ = await cachingClient.flushPendingOperations()
let remaining = await cachingClient.pendingOperations()
guard !remaining.isEmpty else { break }
try? await Task.sleep(for: .seconds(300))
}
}
// Fully automatic this is the only place Full Local Sync actually
// runs from (Settings' "Sync Now" is just an on-demand nudge at the
// same call). Syncs immediately whenever this task (re)starts, which
// covers both "just switched on" and "was already on at launch"
// `.task(id:)` restarts on either, an `@AppStorage`-backed toggle
// changing anywhere updates every view reading that key then every
// 20 minutes after, for as long as it stays enabled.
.task(id: isFullLocalSyncEnabled) {
guard isFullLocalSyncEnabled else { return }
while !Task.isCancelled {
if isEffectivelyOnline, let cachingClient = session.cachingClient {
_ = await cachingClient.performFullSync()
}
try? await Task.sleep(for: .seconds(1200))
}
}
} }
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) { private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
+27 -7
View File
@@ -17,6 +17,16 @@ final class SessionStore {
var teamName: String? var teamName: String?
var teamAvatarURL: URL? var teamAvatarURL: URL?
private(set) var apiClient: OutlineAPIClient? 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? { var serverURL: URL? {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
@@ -26,23 +36,32 @@ final class SessionStore {
self.tokenStore = tokenStore self.tokenStore = tokenStore
self.defaults = defaults self.defaults = defaults
self.isSignedIn = (try? tokenStore.token()) != nil self.isSignedIn = (try? tokenStore.token()) != nil
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
if isSignedIn, let serverURL { if isSignedIn, let serverURL {
apiClient = LiveOutlineAPIClient( (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore
)
} }
} }
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
apiClient = LiveOutlineAPIClient( (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true
}
private static func makeAPIClient(
serverURL: URL,
tokenStore: TokenStoring,
cache: OfflineCacheStore?
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: serverURL), configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore tokenStore: tokenStore
) )
apply(user: user, team: team, serverURL: serverURL) guard let cache else { return (live, nil) }
isSignedIn = true let caching = CachingOutlineAPIClient(live: live, cache: cache)
return (caching, caching)
} }
func signOut() { func signOut() {
@@ -55,6 +74,7 @@ final class SessionStore {
teamName = nil teamName = nil
teamAvatarURL = nil teamAvatarURL = nil
apiClient = nil apiClient = nil
cachingClient = nil
} }
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this /// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
+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()
}
}
+38
View File
@@ -0,0 +1,38 @@
# Security Policy
## Reporting a Vulnerability
If you discover a security vulnerability in Outpost, please do **not**
open a public issue.
Report it privately to: **security@psmattas.com**
Include:
- A description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes if available
We will acknowledge receipt within 48 hours and aim to release a fix
within 14 days depending on severity.
## Supported Versions
Outpost is in early alpha (`0.0.x`) — there's no stable release line
yet. Only the most recent tagged release receives fixes; please make
sure you're on the latest alpha before reporting.
| Version | Supported |
| :--- | :---: |
| Latest tagged release | ✅ |
| Older releases | ❌ |
## Scope
Outpost is a client application that talks to a self-hosted Outline
instance you control — it doesn't run any server infrastructure of its
own. Vulnerabilities in Outline itself belong to
[outline/outline](https://github.com/outline/outline), not this repo.
API tokens are stored in the system Keychain only (never `UserDefaults`,
never logged) — see [`CLAUDE.md`](CLAUDE.md) for the relevant
conventions if you're reviewing that code path.
+91
View File
@@ -0,0 +1,91 @@
# Outpost Setup Guide
## Prerequisites
- macOS with a recent Xcode (Xcode 16 or later)
- A self-hosted Outline instance (or getoutline.com) and a personal API
key — Settings → API Keys on that instance. Outpost only ever talks
to Outline's public REST/WebSocket API; it doesn't vendor or run any
part of Outline's server.
## 1. Clone with submodules
The vendored OpenAPI reference (`docs/reference/outline-openapi`) is a
git submodule.
```bash
git clone --recurse-submodules <repo-url>
# or, if you already cloned without it:
git submodule update --init --recursive
```
To pull that submodule up to whatever's newest upstream:
```bash
git submodule update --remote docs/reference/outline-openapi
```
## 2. Build and test `OutlineKit`
`OutlineKit` is a standalone Swift package (the REST client layer) and
the only part of this repo with a reliable command-line build/test path.
```bash
cd OutlineKit
swift build
swift test
```
## 3. Open the app in Xcode
```bash
open Outpost.xcodeproj
```
- Select the **Outpost** scheme.
- In **Signing & Capabilities**, pick your own team. A free personal
Apple ID team works fine for building and running locally — it just
means the app isn't notarized, so distributed builds trigger
Gatekeeper's "unidentified developer" warning on other machines (see
`scripts/package-dmg.sh`, which ships a README explaining the bypass).
- Run. There's no way to build the app target reliably from the CLI in
this project's current setup — use Xcode.
## 4. Sign in
On first launch, enter your Outline instance's URL and the API key
from step 0. The token is stored in the system Keychain only.
## 5. Packaging a release build
After archiving in Xcode (Product → Archive → Distribute App → Copy
App), package it into a DMG:
```bash
./scripts/package-dmg.sh /path/to/exported/Outpost.app
```
This also prints a Markdown changelog (grouped by commit type, since
the last git tag) for pasting into release notes.
## Where to go next
- [`CLAUDE.md`](CLAUDE.md) — project conventions, phased build order, what's in/out of scope right now.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — deeper technical rationale (API surface, the realtime collaboration transport, the CRDT engine, known risk areas).
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — branching, commit conventions, PR expectations.
## Troubleshooting
### `swift build`/`swift test` fails in `OutlineKit`
Make sure you're running it from inside the `OutlineKit/` directory,
not the repo root — it's a separate Swift package, not part of the
Xcode project's own build.
### Submodule directory is empty
You cloned without `--recurse-submodules`. Run
`git submodule update --init --recursive` from the repo root.
### App builds but sign-in fails
Double-check the server URL (including `https://`) and that the API
key hasn't been revoked on the Outline instance's Settings → API Keys
page.
+6 -1
View File
@@ -98,7 +98,12 @@ if [ -z "$LATEST_TAG" ]; then
HEADING="Changelog" HEADING="Changelog"
else else
HEAD_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)" HEAD_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)"
TAG_COMMIT="$(git -C "$REPO_ROOT" rev-parse "$LATEST_TAG")" # `^{commit}` peels an annotated tag down to the commit it points at —
# without it, `rev-parse` on an annotated tag (e.g. `git tag -a`) returns
# the tag *object's* hash, which never equals a commit hash, so this
# comparison would always take the "not tagged yet" branch below even
# when HEAD genuinely is the tagged commit.
TAG_COMMIT="$(git -C "$REPO_ROOT" rev-parse "${LATEST_TAG}^{commit}")"
if [ "$HEAD_COMMIT" = "$TAG_COMMIT" ]; then if [ "$HEAD_COMMIT" = "$TAG_COMMIT" ]; then
PREV_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 "${LATEST_TAG}^" 2>/dev/null || true)" PREV_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 "${LATEST_TAG}^" 2>/dev/null || true)"
RANGE="${PREV_TAG:+$PREV_TAG..}$LATEST_TAG" RANGE="${PREV_TAG:+$PREV_TAG..}$LATEST_TAG"