Author SHA1 Message Date
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
44 changed files with 2310 additions and 224 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/screenshots/**/*.png
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.
@@ -9,6 +9,10 @@ public protocol OutlineAPIClient: Sendable {
func documentInfo(id: String) 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`.
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
/// 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 shareInfo(documentId: String) 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.
func createPin(_ request: CreatePinRequest) 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`.
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 collectionInfo(id: String) 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] {
try await post("documents.search", body: request)
}
@@ -113,23 +121,38 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
}
public func shareInfo(documentId: String) async throws -> OutlineShare? {
do {
return try await post("shares.info", body: ShareInfoRequest(documentId: documentId))
} catch OutlineAPIError.notFound {
return nil
}
// Real shape confirmed against a live server: `data` is
// `{ shares: [...] }`, not the bare share object the docs imply
// same pattern as `pins.list`. A document could in principle have
// 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 {
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 {
try await post("pins.create", body: request)
}
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 {
@@ -152,6 +175,22 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
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] {
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": ... }`
/// (e.g. `collections.delete`) `post(_:body:)`'s envelope decode doesn't fit.
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
@@ -286,6 +371,15 @@ private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument]
}
private struct PinsListPayload: Decodable {
let pins: [OutlinePin]
let documents: [OutlineDocument]
}
private struct SharesInfoPayload: Decodable {
let shares: [OutlineShare]
}
private struct ListStarsResponse: Decodable {
let stars: [OutlineStar]
}
@@ -314,6 +408,11 @@ private struct DocumentListParams: Encodable {
let limit: Int
}
private struct PaginationParams: Encodable {
let offset: Int
let limit: Int
}
private struct CollectionListParams: Encodable {
let offset: 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
/// A document pinned to the top of a collection (or the team home), backed by
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`)
/// that spec has no `Pins` tag at all but the endpoint exists on Outline's
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed
/// from general knowledge of Outline's API, not verified against this spec;
/// treat field names as best-effort until confirmed against a live server.
/// A document pinned to the top of a collection, or to team Home when
/// `collectionId` is `nil`. Backed by `pins.*` not in the vendored OpenAPI
/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
/// confirmed real against a live server's network traffic. `collectionId: nil`
/// is what "Pin to Home" actually sends; a non-nil value is "Pin to
/// Collection", a distinct action.
public struct OutlinePin: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String
@@ -1,10 +1,41 @@
import Foundation
/// 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 let id: String
public let published: Bool
public let documentId: String?
public let collectionId: String?
public let url: String
public let published: Bool
public let documentTitle: String?
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,7 @@
import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec.
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
/// Collection" these are distinct actions on the real server.
public struct CreatePinRequest: Encodable, Sendable {
public let documentId: String
public let collectionId: 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
/// See `OutlinePin` best-effort shape, not in the vendored spec.
/// See `OutlinePin`. `collectionId: nil` lists Home pins only.
public struct ListPinsRequest: Encodable, Sendable {
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
}
}
@@ -7,12 +7,6 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
public let append: Bool?
public let fullWidth: 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(
id: String,
@@ -20,8 +14,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
text: String? = nil,
append: Bool? = nil,
fullWidth: Bool? = nil,
insightsEnabled: Bool? = nil,
documentEmbeds: Bool? = nil
insightsEnabled: Bool? = nil
) {
self.id = id
self.title = title
@@ -29,6 +22,5 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
self.append = append
self.fullWidth = fullWidth
self.insightsEnabled = insightsEnabled
self.documentEmbeds = documentEmbeds
}
}
@@ -3,9 +3,16 @@ import Foundation
public struct UpdateShareRequest: Encodable, Sendable {
public let id: String
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.published = published
self.title = title
self.iconUrl = iconUrl
}
}
@@ -280,6 +280,65 @@ final class LiveOutlineAPIClientTests: XCTestCase {
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 {
let httpClient = MockHTTPClient()
httpClient.responseData = """
@@ -610,6 +669,117 @@ final class LiveOutlineAPIClientTests: XCTestCase {
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 {
let httpClient = MockHTTPClient()
httpClient.statusCode = 404
@@ -628,6 +798,24 @@ final class LiveOutlineAPIClientTests: XCTestCase {
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 {
let httpClient = MockHTTPClient()
httpClient.responseData = """
@@ -676,6 +864,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
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 {
let httpClient = MockHTTPClient()
httpClient.responseData = """
@@ -694,6 +910,89 @@ final class LiveOutlineAPIClientTests: XCTestCase {
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 {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
+2 -2
View File
@@ -408,7 +408,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.1;
MARKETING_VERSION = 0.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -453,7 +453,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.1;
MARKETING_VERSION = 0.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)";
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>
@@ -9,14 +9,27 @@ import OutlineKit
/// client-side rather than `collections.documents`.
struct CollectionDocumentsOutline: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption
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?
/// Full chain from root to the clicked document (inclusive) lets the
/// toolbar render the real hierarchy instead of just the leaf title.
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] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
}
@@ -26,13 +39,16 @@ struct CollectionDocumentsOutline: View {
collection: OutlineCollection,
sortOption: SidebarSortOption,
refreshToken: Int,
externalRefreshToken: Int,
selectedDocumentID: String?,
onSelectDocument: @escaping ([OutlineDocument]) -> Void
) {
self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption
self.refreshToken = refreshToken
self.externalRefreshToken = externalRefreshToken
self.selectedDocumentID = selectedDocumentID
self.onSelectDocument = onSelectDocument
}
@@ -56,13 +72,26 @@ struct CollectionDocumentsOutline: View {
depth: 0,
ancestors: [],
selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
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.
let ancestors: [OutlineDocument]
let selectedDocumentID: String?
let pinsByDocumentID: [String: OutlinePin]
let onSelectDocument: ([OutlineDocument]) -> Void
let onDocumentsChanged: () async -> Void
let onPinsChanged: () async -> Void
@State private var isExpanded = false
@@ -96,6 +127,7 @@ private struct DocumentNodeRow: View {
@State private var isShowingInsightsSheet = false
@State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String?
private var isSelected: Bool {
@@ -172,8 +204,10 @@ private struct DocumentNodeRow: View {
depth: depth + 1,
ancestors: ancestors + [node.document],
selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument,
onDocumentsChanged: onDocumentsChanged
onDocumentsChanged: onDocumentsChanged,
onPinsChanged: onPinsChanged
)
}
}
@@ -252,6 +286,11 @@ private struct DocumentNodeRow: View {
.sheet(isPresented: $isShowingSearchSheet) {
DocumentSearchSheet(apiClient: apiClient, document: node.document)
}
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialParentDocument: node.document) { _ in
Task { await onDocumentsChanged() }
}
}
}
@ViewBuilder
@@ -259,7 +298,11 @@ private struct DocumentNodeRow: View {
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "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") {}
.disabled(true)
@@ -273,8 +316,9 @@ private struct DocumentNodeRow: View {
renameText = node.document.title
isShowingRenameAlert = true
}
// Sharing/membership management is its own subsystem, not a one-off
// action deferred rather than half-built here.
// DocumentShareSheet now has a real "People with access" section
// 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…") {}
.disabled(true)
@@ -303,11 +347,13 @@ private struct DocumentNodeRow: View {
Button("Import Document…") {}
.disabled(true)
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()
@@ -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 {
do {
_ = 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 {
do {
try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id))
@@ -11,6 +11,8 @@ struct CollectionTreeRow: View {
let isExpanded: Bool
let isSelected: Bool
let selectedDocumentID: String?
/// See the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let onToggle: () -> Void
let onSelectDocument: ([OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void
@@ -22,6 +24,7 @@ struct CollectionTreeRow: View {
@State private var isShowingRenameAlert = false
@State private var renameText = ""
@State private var isShowingDeleteConfirmation = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String?
var body: some View {
@@ -62,6 +65,7 @@ struct CollectionTreeRow: View {
collection: collection,
sortOption: sortOption,
refreshToken: documentsRefreshToken,
externalRefreshToken: externalRefreshToken,
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument
)
@@ -92,6 +96,12 @@ struct CollectionTreeRow: View {
} message: {
Text(actionErrorMessage ?? "")
}
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in
documentsRefreshToken += 1
Task { await onCollectionsChanged() }
}
}
}
@ViewBuilder
@@ -103,7 +113,7 @@ struct CollectionTreeRow: View {
Divider()
Button("New Document") {
Task { await createDocument() }
isShowingNewDocumentSheet = true
}
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 {
do {
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
@@ -7,6 +7,12 @@ struct CollectionsTreeView: View {
@Binding private var selectedCollection: OutlineCollection?
let selectedDocumentID: 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 onSearchInCollection: (OutlineCollection) -> Void
@@ -14,12 +20,18 @@ struct CollectionsTreeView: View {
apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>,
selectedDocumentID: String?,
externalRefreshToken: Int,
isShowingHome: Bool,
onSelectHome: @escaping () -> Void,
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
onSearchInCollection: @escaping (OutlineCollection) -> Void
) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
_selectedCollection = selectedCollection
self.selectedDocumentID = selectedDocumentID
self.externalRefreshToken = externalRefreshToken
self.isShowingHome = isShowingHome
self.onSelectHome = onSelectHome
self.onSelectDocument = onSelectDocument
self.onSearchInCollection = onSearchInCollection
}
@@ -31,6 +43,7 @@ struct CollectionsTreeView: View {
Task { await viewModel.load() }
}
}
homeRow
content
}
.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
private var content: some View {
Group {
@@ -81,6 +120,7 @@ struct CollectionsTreeView: View {
isExpanded: expandedCollectionIDs.contains(collection.id),
isSelected: selectedCollection?.id == collection.id,
selectedDocumentID: selectedDocumentID,
externalRefreshToken: externalRefreshToken,
onToggle: { toggle(collection) },
onSelectDocument: { chain in onSelectDocument(collection, chain) },
onSearchInCollection: onSearchInCollection,
@@ -95,13 +135,6 @@ struct CollectionsTreeView: View {
}
.task {
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,
// global search) wouldn't otherwise expand its collection here, so
@@ -4,6 +4,10 @@ import OutlineKit
struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
/// 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?
/// 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.
@@ -12,6 +16,11 @@ struct ContentView_macOS: View {
@State private var contextualSearchQuery = ""
@State private var isContextualSearchExpanded = false
@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 {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -43,18 +52,48 @@ struct ContentView_macOS: View {
// mutually exclusive: whenever a document's pushed (back button
// visible), this shows the document hierarchy instead of falling
// back to the workspace badge.
ToolbarItem(placement: .navigation) {
Button {
goHome()
} label: {
Image(systemName: "house")
}
.help("Home")
}
ToolbarItem(placement: .navigation) {
leadingToolbarContent
}
// Custom instead of `.searchable`: that modifier always renders a
// full-width field, but this is meant to sit alongside the other
// 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) {
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
replaceDocumentPath(with: [])
}
@ViewBuilder
private var contextualSearchField: some View {
@@ -106,12 +145,19 @@ struct ContentView_macOS: View {
Image(systemName: "magnifyingglass")
Text("Search")
}
} else if !documentPath.isEmpty, let selectedCollection {
// Collection every ancestor (icon only) current document
// (icon + full title) ancestors stay icon-only so a deep
// chain doesn't blow out the toolbar width.
} else if !documentPath.isEmpty {
// Origin (collection, or Home if opened from there) every
// ancestor (icon only) current document (icon + full
// 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) {
if let selectedCollection {
CollectionRowView(collection: selectedCollection)
} else {
Image(systemName: "house.fill")
}
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
Image(systemName: "chevron.right")
@@ -130,6 +176,11 @@ struct ContentView_macOS: View {
}
}
}
} else if isShowingHome {
HStack(spacing: 6) {
Image(systemName: "house.fill")
Text("Home")
}
} else if let selectedCollection {
CollectionRowView(collection: selectedCollection)
} else {
@@ -153,6 +204,9 @@ struct ContentView_macOS: View {
apiClient: apiClient,
selectedCollection: $selectedCollection,
selectedDocumentID: documentPath.last?.id,
externalRefreshToken: documentsChangedToken,
isShowingHome: isShowingHome,
onSelectHome: goHome,
onSelectDocument: selectDocumentChain,
onSearchInCollection: searchInCollection
)
@@ -205,6 +259,8 @@ struct ContentView_macOS: View {
Group {
if !trimmedGlobalQuery.isEmpty {
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
} else if isShowingHome {
HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 })
} else if let selectedCollection {
CollectionOverviewView(
apiClient: apiClient,
@@ -229,11 +285,12 @@ struct ContentView_macOS: View {
if !documentPath.isEmpty {
documentPath.removeLast()
}
}
},
onDocumentCreated: { documentsChangedToken += 1 }
)
}
}
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
.id(trimmedGlobalQuery.isEmpty ? (isShowingHome ? "home" : (selectedCollection?.id ?? "none")) : "search")
} else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
}
@@ -23,6 +23,11 @@ struct DocumentReaderView: View {
/// longer visible in the collection it was opened from, so the reader
/// pops itself off the navigation stack.
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 isShowingArchiveConfirmation = false
@@ -33,19 +38,22 @@ struct DocumentReaderView: View {
@State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false
@State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String?
init(
apiClient: OutlineAPIClient,
document: OutlineDocument,
onOpenChild: @escaping (OutlineDocument) -> Void,
onDeleted: @escaping () -> Void
onDeleted: @escaping () -> Void,
onDocumentCreated: @escaping () -> Void
) {
self.apiClient = apiClient
self.document = document
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
self.onOpenChild = onOpenChild
self.onDeleted = onDeleted
self.onDocumentCreated = onDocumentCreated
}
var body: some View {
@@ -103,6 +111,9 @@ struct DocumentReaderView: View {
Image(systemName: "square.and.arrow.up")
}
.help("Share")
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
}
Button {
Task { await viewModel.toggleEditing() }
@@ -116,7 +127,7 @@ struct DocumentReaderView: View {
.disabled(viewModel.isSaving)
Button {
Task { await createChildDocument() }
isShowingNewDocumentSheet = true
} label: {
Image(systemName: "doc.badge.plus")
}
@@ -127,12 +138,22 @@ struct DocumentReaderView: View {
} label: {
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.loadPinAndSubscriptionState()
}
.task {
await viewModel.loadInsightsEnabledState()
}
.task {
while !Task.isCancelled {
await viewModel.loadViewers()
@@ -197,14 +218,33 @@ struct DocumentReaderView: View {
.sheet(isPresented: $isShowingSearchSheet) {
DocumentSearchSheet(apiClient: apiClient, document: document)
}
.sheet(isPresented: $isShowingShareSheet) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
.sheet(isPresented: $isShowingNewDocumentSheet) {
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
].map(String.init).joined(separator: "-")
}
@ViewBuilder
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) {
ForEach(viewModel.viewers.prefix(5)) { viewer in
AvatarBadge(
@@ -249,19 +289,22 @@ struct DocumentReaderView: View {
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
Task { await star() }
}
Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") {
Task { await toggleSubscription() }
}
Toggle("Subscribed", isOn: Binding(
get: { viewModel.isSubscribed },
set: { _ in Task { await toggleSubscription() } }
))
Divider()
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() }
}
// Sharing/membership management is its own subsystem, not a one-off
// action deferred rather than half-built here.
Button("Permissions…") {}
.disabled(true)
// Membership management now lives in DocumentShareSheet's "People
// with access" section, alongside the share link same sheet,
// same isShowingShareSheet state.
Button("Permissions…") {
isShowingShareSheet = true
}
Divider()
@@ -288,9 +331,9 @@ struct DocumentReaderView: View {
Button("Import Document…") {}
.disabled(true)
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() }
}
@@ -323,15 +366,20 @@ struct DocumentReaderView: View {
Divider()
Button("Enable Viewer Insights") {
Task { await enableInsights() }
}
Button("Enable Embeds") {
Task { await enableEmbeds() }
}
Button(viewModel.isFullWidth ? "Default Width" : "Full Width") {
Task { await toggleFullWidth() }
}
Toggle("Viewer Insights", isOn: Binding(
get: { viewModel.isInsightsEnabled ?? false },
set: { _ in Task { await toggleInsights() } }
))
// Confirmed against a live server: there's no per-document embeds
// field. Only a workspace-level setting exists, and that's not
// reachable via the API either (no `team.update` endpoint in the
// 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()
@@ -372,19 +420,11 @@ struct DocumentReaderView: View {
}
}
private func enableInsights() async {
private func toggleInsights() async {
do {
try await viewModel.enableViewerInsights()
try await viewModel.toggleViewerInsights()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.")
}
}
private func enableEmbeds() async {
do {
try await viewModel.enableEmbeds()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.")
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.")
}
}
@@ -431,21 +471,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 {
do {
let markdown = try await apiClient.exportDocument(id: viewModel.documentId)
@@ -28,7 +28,12 @@ final class DocumentReaderViewModel {
private var pinId: String?
private(set) var isSubscribed = false
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
private let apiClient: OutlineAPIClient
@@ -75,7 +80,9 @@ final class DocumentReaderViewModel {
}
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 }) {
isPinned = true
pinId = match.id
@@ -94,8 +101,13 @@ final class DocumentReaderViewModel {
}
}
func loadShare() async {
share = try? await apiClient.shareInfo(documentId: documentId)
func loadInsightsEnabledState() async {
do {
_ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId))
isInsightsEnabled = true
} catch {
isInsightsEnabled = false
}
}
func togglePin() async throws {
@@ -110,7 +122,7 @@ final class DocumentReaderViewModel {
throw error
}
} 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
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.
func toggleEditing() async {
guard isEditing else {
@@ -168,14 +176,14 @@ final class DocumentReaderViewModel {
}
}
/// Fire-and-forget: `insightsEnabled` isn't readable back off `Document`
/// in the vendored spec, so there's no state to reflect as a checkmark.
func enableViewerInsights() async throws {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true))
func toggleViewerInsights() async throws {
let newValue = !(isInsightsEnabled ?? false)
isInsightsEnabled = newValue
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 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
struct DocumentShareSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let documentId: String
@State private var share: OutlineShare?
@State private var isLoading = false
@State private var isUpdating = false
@State private var errorMessage: String?
@State private var titleOverride = ""
@State private var isLoadingShare = false
@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 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 {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 0) {
Text("Share")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
.padding(.horizontal, 16)
.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 {
ProgressView().frame(maxWidth: .infinity)
} else if let errorMessage {
Text(errorMessage)
// MARK: - Link section
@ViewBuilder
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)
.foregroundStyle(.red)
} else if let share {
HStack {
Text(share.url)
VStack(alignment: .leading, spacing: 6) {
if let url = share.url {
HStack(spacing: 8) {
Image(systemName: "globe")
.foregroundStyle(.secondary)
.font(.callout)
Text(url)
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Spacer(minLength: 0)
Button {
copyLink(share.url)
copyLink(url)
} label: {
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)
}
.padding(8)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6))
.padding(.vertical, 4)
}
Toggle(
"Published — accessible without sign-in",
isOn: Binding(
get: { share.published },
set: { newValue in Task { await setPublished(newValue) } }
)
)
.disabled(isUpdating)
} else {
Button("Create Share Link") {
Task { await create() }
private func avatar(for name: String) -> some View {
Circle()
.fill(.fill.secondary)
.frame(width: 22, height: 22)
.overlay {
Text(initials(for: name))
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
}
}
}
.padding(20)
.frame(width: 380)
.task { await load() }
private func initials(for name: String) -> String {
let parts = name.split(separator: " ").prefix(2)
let letters = parts.compactMap { $0.first }
return letters.isEmpty ? "?" : String(letters).uppercased()
}
private func copyLink(_ url: String) {
@@ -78,34 +316,96 @@ struct DocumentShareSheet: View {
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
private func loadShare() async {
isLoadingShare = true
defer { isLoadingShare = false }
do {
share = try await apiClient.shareInfo(documentId: documentId)
titleOverride = share?.title ?? ""
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
}
}
private func create() async {
isLoading = true
defer { isLoading = false }
isLoadingShare = true
defer { isLoadingShare = false }
do {
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
titleOverride = share?.title ?? ""
} 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 }
isUpdating = true
defer { isUpdating = false }
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed != (share.title ?? "") else { return }
isUpdatingShare = true
defer { isUpdatingShare = false }
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 {
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,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
+135
View File
@@ -0,0 +1,135 @@
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 shouldn't pop the refresh banner.
func checkForRemoteChanges(tab: HomeTab) async {
async let freshPinned = fetchPinned()
guard let freshTab = try? await fetch(tab: tab) else { return }
let pinned = await freshPinned
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
hasRemoteChanges = true
}
}
/// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar.
private func fetchPinned() async -> [OutlineDocument] {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
return []
}
var documents: [OutlineDocument] = []
for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
documents.append(document)
}
}
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
+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"
else
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
PREV_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 "${LATEST_TAG}^" 2>/dev/null || true)"
RANGE="${PREV_TAG:+$PREV_TAG..}$LATEST_TAG"