4 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 195dc2cc59 feat(editor): Remember previous location + Command Palette (⌘K)
Remember previous location (Preferences): persists collection +
document chain (or Home) to UserDefaults on every navigation change,
gated on the preference. Restored once per launch by resolving the
stored IDs back through the API, stopping at the first failure
(deleted doc, offline, etc.) rather than aborting the whole restore —
a partial chain beats falling all the way back to Home. Cleared on
sign-out so switching accounts can't restore a stale location.

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

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

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

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

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

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

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

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

Prerequisite: SessionStore now caches OutlineUserPreferences to
UserDefaults, loaded on launch before any network call — this needed
to stay valid on a cold offline launch, not just live in memory from
the last successful fetch. Still read-only while offline, unchanged.
2026-08-19 15:29:38 +01:00
11 changed files with 781 additions and 100 deletions
@@ -433,6 +433,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
/// existing cached-read methods already do the caching as a side effect, /// existing cached-read methods already do the caching as a side effect,
/// this just has to drive the walk and separately cache each document by /// this just has to drive the walk and separately cache each document by
/// id (`listDocuments`'s cache key is the list, not the individual doc). /// id (`listDocuments`'s cache key is the list, not the individual doc).
///
/// Recurses into every document's children, not just collections' own
/// root-level documents a document with sub-documents used to leave
/// them uncached entirely (only reachable if something else happened to
/// open them individually first). Also caches each collection under its
/// own `"collection:<id>"` key (previously only cached as part of the
/// paginated list blob), so both are individually enumerable afterward
/// via `OfflineCacheStore.loadAll(keyPrefix:)` see
/// `cachedDocumentsIndex()`/`cachedCollectionsIndex()`.
public func performFullSync() async -> FullSyncSummary { public func performFullSync() async -> FullSyncSummary {
var documentsCount = 0 var documentsCount = 0
var errors: [String] = [] var errors: [String] = []
@@ -457,26 +466,62 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
} }
for collection in collections { for collection in collections {
await cacheCollection(collection)
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
documentsCount += result.count
errors.append(contentsOf: result.errors)
}
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
}
/// Caches every document under `parentDocumentId` (`nil` = a
/// collection's root level) and recurses into each one's own children,
/// depth-first, until a branch runs out of sub-documents. Returns a
/// plain `(count, errors)` pair rather than mutating shared state across
/// `await` boundaries, since this calls itself recursively.
private func cacheDocumentTree(
collectionId: String,
parentDocumentId: String?,
collectionName: String
) async -> (count: Int, errors: [String]) {
var count = 0
var errors: [String] = []
var offset = 0 var offset = 0
let limit = 100 let limit = 100
while true { while true {
let documents: [OutlineDocument] let documents: [OutlineDocument]
do { do {
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit) documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
} catch { } catch {
errors.append("\(collection.name): \(errorDescription(error))") errors.append("\(collectionName): \(errorDescription(error))")
break break
} }
for document in documents { for document in documents {
await cacheDocument(document) await cacheDocument(document)
count += 1
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
count += childResult.count
errors.append(contentsOf: childResult.errors)
} }
documentsCount += documents.count
guard documents.count == limit else { break } guard documents.count == limit else { break }
offset += limit offset += limit
} }
return (count, errors)
} }
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date()) /// Every individually cached document from the last Full Local Sync
/// empty if a sync has never run (or found nothing). Purely a local
/// SwiftData read, no network involved.
public func cachedDocumentsIndex() async -> [OutlineDocument] {
let payloads = await cache.loadAll(keyPrefix: "document:")
return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) }
}
/// Every individually cached collection from the last Full Local Sync.
public func cachedCollectionsIndex() async -> [OutlineCollection] {
let payloads = await cache.loadAll(keyPrefix: "collection:")
return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) }
} }
// MARK: - Helpers // MARK: - Helpers
@@ -31,6 +31,15 @@ public actor OfflineCacheStore {
return try? modelContext.fetch(descriptor).first?.payload return try? modelContext.fetch(descriptor).first?.payload
} }
/// Everything cached under a key prefix e.g. every individually
/// cached document (`"document:<id>"`) or collection
/// (`"collection:<id>"`) after a Full Local Sync, for building a local
/// search index without a per-item exact-key lookup.
public func loadAll(keyPrefix: String) -> [Data] {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key.starts(with: keyPrefix) })
return ((try? modelContext.fetch(descriptor)) ?? []).map(\.payload)
}
/// Used to drop a temporary `pending-*` document's cache entry once a /// Used to drop a temporary `pending-*` document's cache entry once a
/// queued create syncs and the server hands back the real id the /// queued create syncs and the server hands back the real id the
/// placeholder key would otherwise sit around as a dead orphan forever. /// placeholder key would otherwise sit around as a dead orphan forever.
@@ -421,8 +421,13 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testPerformFullSyncCachesEachDocumentIndividually() async throws { func testPerformFullSyncCachesEachDocumentIndividually() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] } stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
stub.listDocumentsHandler = { _, _, offset, _ in // Must return empty for any non-nil parentDocumentId (no children)
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : [] // performFullSync now recurses into every document's own children,
// so a stub that ignores parentDocumentId and always returns the
// same root documents regardless would recurse into itself forever.
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
guard parentDocumentId == nil else { return [] }
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache) let sut = CachingOutlineAPIClient(live: stub, cache: cache)
@@ -437,6 +442,34 @@ final class CachingOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(cachedDoc.id, "doc-2") XCTAssertEqual(cachedDoc.id, "doc-2")
} }
func testPerformFullSyncRecursesIntoNestedDocuments() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
// doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild)
// regression test for the real gap this fixed: only root-level
// documents were ever cached before, so a document's own
// sub-documents were never reachable offline at all unless
// something else happened to open them individually first.
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
guard offset == 0 else { return [] }
switch parentDocumentId {
case nil: return [self.makeDocument(id: "doc-1")]
case "doc-1": return [self.makeDocument(id: "doc-2")]
case "doc-2": return [self.makeDocument(id: "doc-3")]
default: return []
}
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 3)
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedGrandchild = try await sut.documentInfo(id: "doc-3")
XCTAssertEqual(cachedGrandchild.id, "doc-3")
}
// MARK: - Offline document creation // MARK: - Offline document creation
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws { func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
@@ -14,6 +14,9 @@ struct SettingsView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false @AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false
@@ -92,6 +95,7 @@ struct SettingsView: View {
private var sectionDetail: some View { private var sectionDetail: some View {
switch section { switch section {
case .appearance: appearanceDetail case .appearance: appearanceDetail
case .editor: editorDetail
case .profile: profileDetail case .profile: profileDetail
case .preferences: preferencesDetail case .preferences: preferencesDetail
case .notifications: notificationsDetail case .notifications: notificationsDetail
@@ -139,6 +143,62 @@ struct SettingsView: View {
} }
} }
// MARK: - Editor
/// Local-only, device-side settings for how this app's own editor
/// behaves not synced to Outline (unlike Preferences, which mirrors
/// server-side settings the web app also reads/writes). Same category
/// `.general`/"Outpost" as Appearance, for the same reason.
private var editorDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
Text("Settings for how documents are edited in this app.")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Toggle("Split View", isOn: $isSplitViewEnabled)
Text("Edit raw Markdown on the left with a live-updating preview on the right, instead of a single editable view.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Toggle("Search Entire Workspace", isOn: $isCommandPaletteFullWorkspaceSearch)
.disabled(!isCommandPaletteEnabled || !isFullLocalSyncEnabled)
Text(fullWorkspaceSearchDescription)
.font(.caption)
.foregroundStyle(isFullLocalSyncEnabled ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.orange))
}
.frame(maxWidth: 480, alignment: .leading)
.opacity(isCommandPaletteEnabled ? 1 : 0.4)
}
}
/// Full Workspace mode reads Full Local Sync's own SwiftData cache
/// directly zero network calls, and it's the only way to get nested
/// sub-documents included (the live per-collection fetch this used to
/// do could only ever see collection-root documents). Requires that
/// cache to actually exist first, so the toggle above stays disabled,
/// and this explains why, until Offline & Sync Full Local Sync is on.
private var fullWorkspaceSearchDescription: String {
guard isFullLocalSyncEnabled else {
return "Requires Full Local Sync (Offline & Sync) — turn that on first so there's a local copy of your workspace to search."
}
return "Off (default): only your collections and recently viewed documents — near-instant. On: every document and collection from Full Local Sync's local copy, including nested sub-documents — entirely offline, no network request at all."
}
// MARK: - Profile // MARK: - Profile
private var profileDetail: some View { private var profileDetail: some View {
@@ -0,0 +1,247 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// K. Settings Editor Command Palette. Always searches locally, never a
/// per-keystroke network request. Two data-source modes:
///
/// - Lightweight (default): a live `listCollections` + `listViewedDocuments`
/// fetch once when the palette opens two small requests, near-instant,
/// works with no setup.
/// - Full Workspace: reads `CachingOutlineAPIClient`'s local SwiftData cache
/// directly (`cachedDocumentsIndex()`/`cachedCollectionsIndex()`) zero
/// network calls at all, and includes every nested sub-document, not just
/// collection roots. Requires Full Local Sync to actually have populated
/// that cache first (gated in Settings the toggle here is disabled
/// without it); this view doesn't trigger a sync itself.
struct CommandPaletteView: View {
let apiClient: OutlineAPIClient
let cachingClient: CachingOutlineAPIClient?
let fullWorkspaceSearch: Bool
let onSelectDocument: (OutlineDocument) -> Void
let onSelectCollection: (OutlineCollection) -> Void
let onDismiss: () -> Void
@State private var query = ""
@State private var collections: [OutlineCollection] = []
@State private var documents: [OutlineDocument] = []
@State private var isLoading = true
@State private var selectedIndex = 0
@FocusState private var isSearchFieldFocused: Bool
private enum Result: Identifiable {
case collection(OutlineCollection)
case document(OutlineDocument)
var id: String {
switch self {
case .collection(let collection): return "collection-\(collection.id)"
case .document(let document): return "document-\(document.id)"
}
}
}
private var results: [Result] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
// No query yet: surface collections first, then the most
// recent/full-workspace documents as-is, capped so the panel
// doesn't dump the entire workspace with nothing typed.
return (collections.map(Result.collection) + documents.map(Result.document))
.prefix(20)
.map { $0 }
}
let scored: [(Result, Int)] = collections.compactMap { collection in
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
} + documents.compactMap { document in
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
}
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
}
/// Lower is better exact match, then prefix match, then earliest
/// contiguous-substring position, then (for multi-word queries) every
/// word present somewhere in the title in any order. That last tier is
/// what makes "test document" find a title like "Test Plan Document"
/// requiring the exact phrase contiguously (the previous behavior)
/// meant a title with anything between the words never matched at all,
/// which looked like "documents never show up, only collections" any
/// time the real title didn't happen to contain the typed phrase
/// verbatim. `nil` means no match at all. Still deliberately not a full
/// fuzzy/Levenshtein algorithm good enough for document/collection
/// titles without the unpredictability that brings.
private func matchScore(_ title: String, query: String) -> Int? {
let haystack = title.lowercased()
let needle = query.lowercased()
if haystack == needle { return 0 }
if haystack.hasPrefix(needle) { return 1 }
if let range = haystack.range(of: needle) {
return 2 + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
}
let words = needle.split(separator: " ").map(String.init)
guard words.count > 1, words.allSatisfy({ haystack.contains($0) }) else { return nil }
let totalPosition = words.reduce(0) { partial, word in
guard let range = haystack.range(of: word) else { return partial }
return partial + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
}
return 100 + totalPosition
}
var body: some View {
ZStack {
Color.black.opacity(0.001) // catches clicks outside the card to dismiss
.onTapGesture { onDismiss() }
VStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search documents and collections…", text: $query)
.textFieldStyle(.plain)
.font(.title3)
.focused($isSearchFieldFocused)
.onChange(of: query) { selectedIndex = 0 }
.onSubmit { selectCurrent() }
// Attached directly on the field itself, not an
// ancestor confirmed live that .onKeyPress on the
// outer card never saw arrow-key events at all while
// this TextField actually held focus, the up/down
// presses just went nowhere. Escape still needs its
// own handler below since this one only covers
// whichever view is actually focused.
.onKeyPress(.downArrow) { moveSelection(by: 1); return .handled }
.onKeyPress(.upArrow) { moveSelection(by: -1); return .handled }
.onKeyPress(.escape) { onDismiss(); return .handled }
if isLoading {
ProgressView().controlSize(.small)
}
}
.padding(14)
Divider()
if results.isEmpty {
ContentUnavailableView(
isLoading ? "Loading…" : "No Results",
systemImage: isLoading ? "ellipsis" : "magnifyingglass"
)
.frame(height: 160)
} else {
ScrollViewReader { scrollProxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
resultRow(result, isSelected: index == selectedIndex)
.id(index)
.contentShape(Rectangle())
.onTapGesture {
selectedIndex = index
selectCurrent()
}
}
}
.padding(6)
}
.frame(maxHeight: 360)
.onChange(of: selectedIndex) { _, newValue in
scrollProxy.scrollTo(newValue, anchor: .center)
}
}
}
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(.separator))
.frame(width: 560)
.shadow(color: .black.opacity(0.3), radius: 24, y: 12)
}
.task {
// The window/responder chain isn't always ready to accept a
// first-responder change in the same instant this view is
// inserted confirmed live: setting this synchronously on
// appear left the field unfocused until manually clicked
// (also the likely source of several "entangle context after
// pre-commit" / CA-transaction warnings in the console, which
// are exactly what fighting AppKit for first-responder status
// mid-commit looks like). A one-frame-ish delay is enough for
// the overlay's insertion to settle first.
try? await Task.sleep(for: .milliseconds(50))
isSearchFieldFocused = true
await loadResults()
}
}
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
HStack(spacing: 10) {
switch result {
case .collection(let collection):
// Reuses the sidebar's own icon logic (emoji vs Outline's
// icon-key-to-SF-Symbol mapping vs fallback) instead of
// guessing `collection.icon` isn't a raw SF Symbol name.
CollectionRowView(collection: collection)
.labelStyle(.iconOnly)
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(collection.name)
.lineLimit(1)
Text("Collection")
.font(.caption2)
.foregroundStyle(.secondary)
}
case .document(let document):
if let emoji = document.emoji {
Text(emoji).frame(width: 20)
} else {
Image(systemName: "doc.text")
.foregroundStyle(.secondary)
.frame(width: 20)
}
VStack(alignment: .leading, spacing: 1) {
Text(document.title.isEmpty ? "Untitled" : document.title)
.lineLimit(1)
Text("Document")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
private func moveSelection(by delta: Int) {
guard !results.isEmpty else { return }
selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
}
private func selectCurrent() {
guard results.indices.contains(selectedIndex) else { return }
switch results[selectedIndex] {
case .collection(let collection): onSelectCollection(collection)
case .document(let document): onSelectDocument(document)
}
onDismiss()
}
private func loadResults() async {
isLoading = true
defer { isLoading = false }
if fullWorkspaceSearch {
// Purely local SwiftData reads no network at all, and (since
// Full Local Sync now recurses into every document's children)
// this includes nested sub-documents the live per-collection
// fetch never could. Empty if a sync has never actually run.
collections = await cachingClient?.cachedCollectionsIndex() ?? []
documents = await cachingClient?.cachedDocumentsIndex() ?? []
return
}
async let fetchedCollections = (try? apiClient.listCollections(offset: 0, limit: 250)) ?? []
async let fetchedRecent = (try? apiClient.listViewedDocuments(offset: 0, limit: 30)) ?? []
collections = await fetchedCollections
documents = await fetchedRecent
}
}
#endif
@@ -6,6 +6,7 @@ struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation @Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
/// The landing state no collection selected yet is what Home actually /// The landing state no collection selected yet is what Home actually
/// means, so this starts `true` rather than auto-selecting the first /// means, so this starts `true` rather than auto-selecting the first
/// collection the way this used to work. /// collection the way this used to work.
@@ -23,6 +24,11 @@ struct ContentView_macOS: View {
/// Home's "New Document" buttons) every expanded sidebar row reloads /// Home's "New Document" buttons) every expanded sidebar row reloads
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`. /// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
@State private var documentsChangedToken = 0 @State private var documentsChangedToken = 0
/// Guards the restore-on-launch attempt to exactly once per app launch
/// without this, `mainContent`'s `.task` would re-run (and
/// re-navigate out from under the user) every time it reappears, e.g.
/// after a trip through Settings.
@State private var hasAttemptedLocationRestore = false
private var trimmedGlobalQuery: String { private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -147,6 +153,38 @@ struct ContentView_macOS: View {
if newValue != nil { if newValue != nil {
isShowingHome = false isShowingHome = false
} }
persistLastLocationIfEnabled()
}
.onChange(of: documentPath) { _, _ in
persistLastLocationIfEnabled()
}
.onChange(of: isShowingHome) { _, _ in
persistLastLocationIfEnabled()
}
// Once per launch, before the user has a chance to navigate
// manually restores whatever `restoreLastLocationIfEnabled`
// finds, or leaves today's Home default alone if there's nothing
// to restore (preference off, nothing stored yet, or resolution
// fails e.g. a deleted document/collection or being offline).
.task {
guard !hasAttemptedLocationRestore else { return }
hasAttemptedLocationRestore = true
await restoreLastLocationIfEnabled()
}
.overlay {
if navigation.isShowingCommandPalette, let apiClient = session.apiClient {
CommandPaletteView(
apiClient: apiClient,
cachingClient: session.cachingClient,
fullWorkspaceSearch: isCommandPaletteFullWorkspaceSearch,
onSelectDocument: openDocument,
onSelectCollection: { collection in
selectedCollection = collection
replaceDocumentPath(with: [])
},
onDismiss: { navigation.isShowingCommandPalette = false }
)
}
} }
} }
@@ -160,6 +198,63 @@ struct ContentView_macOS: View {
replaceDocumentPath(with: []) replaceDocumentPath(with: [])
} }
// MARK: - Remember previous location (Preferences Remember previous location)
private static let lastLocationDefaultsKey = "outline.lastLocation"
/// What gets persisted `isHome` disambiguates "was on Home" from "no
/// collection selected yet" (the latter only otherwise happens on the
/// brief `ContentUnavailableView` placeholder state), since both would
/// otherwise look identical (`collectionId == nil`).
private struct LastLocation: Codable {
var isHome: Bool
var collectionId: String?
var documentIds: [String]
}
/// Called from every navigation-changing `.onChange` cheap to persist
/// on every change rather than debouncing, this is just a small JSON
/// blob in `UserDefaults`, not a network call.
private func persistLastLocationIfEnabled() {
guard session.userPreferences?.rememberLastPath == true else { return }
let location = LastLocation(isHome: isShowingHome, collectionId: selectedCollection?.id, documentIds: documentPath.map(\.id))
guard let data = try? JSONEncoder().encode(location) else { return }
UserDefaults.standard.set(data, forKey: Self.lastLocationDefaultsKey)
}
/// Resolves IDs back into real `OutlineCollection`/`OutlineDocument`
/// objects via the API stored IDs alone aren't enough to populate
/// `selectedCollection`/`documentPath` directly. Resolves the document
/// chain in order and stops at the first failure (deleted document,
/// offline, etc.) rather than aborting the whole restore whatever
/// prefix of the chain resolved successfully is still a better landing
/// spot than falling all the way back to Home.
private func restoreLastLocationIfEnabled() async {
guard session.userPreferences?.rememberLastPath == true,
let apiClient = session.apiClient,
let data = UserDefaults.standard.data(forKey: Self.lastLocationDefaultsKey),
let location = try? JSONDecoder().decode(LastLocation.self, from: data)
else { return }
// A pure "was on Home, nothing pushed" location needs no action
// Home is already the default state before this ever runs.
guard location.collectionId != nil || !location.documentIds.isEmpty else { return }
if let collectionId = location.collectionId {
guard let collection = try? await apiClient.collectionInfo(id: collectionId) else { return }
selectedCollection = collection
isShowingHome = false
}
var resolvedChain: [OutlineDocument] = []
for documentId in location.documentIds {
guard let document = try? await apiClient.documentInfo(id: documentId) else { break }
resolvedChain.append(document)
}
if !resolvedChain.isEmpty {
replaceDocumentPath(with: resolvedChain)
}
}
@ViewBuilder @ViewBuilder
private var contextualSearchField: some View { private var contextualSearchField: some View {
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty { if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
@@ -13,6 +13,9 @@ struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore @Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Local-only Outpost setting (Settings Editor), not synced to
/// Outline see `SettingsView.editorDetail`.
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@State private var viewModel: DocumentReaderViewModel @State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
@@ -51,6 +54,11 @@ struct DocumentReaderView: View {
) { ) {
self.apiClient = apiClient self.apiClient = apiClient
self.document = document self.document = document
// `separateEditingEnabled` can't be read from `@Environment` here
// environment values aren't populated yet inside a view's `init`,
// only from `body` onward. Defaults to `true` (today's only
// behavior) and gets set for real in `.task` below once `session`
// is actually available.
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
self.onOpenChild = onOpenChild self.onOpenChild = onOpenChild
self.onDeleted = onDeleted self.onDeleted = onDeleted
@@ -66,45 +74,27 @@ struct DocumentReaderView: View {
session.networkMonitor.isOnline && !isOfflineModeEnabled session.networkMonitor.isOnline && !isOfflineModeEnabled
} }
/// Split View needs the full window height (each pane scrolls itself),
/// which an unbounded page-level `ScrollView` can't give it a
/// `minHeight` inside one just resolves to exactly that minimum, not
/// "fill available space", since there's no bounded space to fill.
/// Only switches over once there's real content to show; loading/error
/// states still go through the normal scrolling layout.
private var canShowSplitView: Bool {
isSplitViewEnabled
&& viewModel.isEffectivelyEditable
&& viewModel.errorMessage == nil
&& !(viewModel.isLoading && viewModel.text.isEmpty)
}
var body: some View { var body: some View {
ScrollView { Group {
VStack(alignment: .leading, spacing: 12) { if canShowSplitView {
if viewModel.isEditing { splitViewContent
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else { } else {
NativeTextViewWrapper( scrollingReaderContent
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEditing
)
if !viewModel.children.isEmpty {
childrenSection
} }
} }
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
if viewModel.isLoading && !viewModel.text.isEmpty { if viewModel.isLoading && !viewModel.text.isEmpty {
ProgressView() ProgressView()
@@ -127,6 +117,7 @@ struct DocumentReaderView: View {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
} }
if viewModel.separateEditingEnabled {
Button { Button {
Task { await viewModel.toggleEditing() } Task { await viewModel.toggleEditing() }
} label: { } label: {
@@ -137,6 +128,13 @@ struct DocumentReaderView: View {
} }
} }
.disabled(viewModel.isSaving) .disabled(viewModel.isSaving)
} else if viewModel.isSaving {
// No Edit/Done affordance when documents are always
// editable this is the only feedback that an autosave
// is actually happening.
ProgressView().controlSize(.small)
.help("Saving…")
}
Button { Button {
isShowingNewDocumentSheet = true isShowingNewDocumentSheet = true
@@ -160,6 +158,17 @@ struct DocumentReaderView: View {
} }
} }
.task { await viewModel.loadFullContent() } .task { await viewModel.loadFullContent() }
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
// for why this can't just be read at `init` time.
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
.onChange(of: viewModel.text) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.onChange(of: viewModel.title) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.task { .task {
await viewModel.loadPinAndSubscriptionState() await viewModel.loadPinAndSubscriptionState()
} }
@@ -272,29 +281,97 @@ struct DocumentReaderView: View {
} }
} }
private var childrenSection: some View { /// Today's single-pane layout page-level `ScrollView` wrapping title +
VStack(alignment: .leading, spacing: 8) { /// content, used for the normal reading/editing view, and for every
Divider() /// loading/error state regardless of Split View.
.padding(.vertical, 4) private var scrollingReaderContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if viewModel.isEffectivelyEditable {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
Text("Sub-documents") if viewModel.isLoading && viewModel.text.isEmpty {
.font(.caption.weight(.semibold)) ProgressView()
.foregroundStyle(.secondary) .frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable
)
}
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
}
ForEach(viewModel.children) { child in /// Split View's layout title fixed at the top (not part of either
Button { /// scrolling pane), `splitEditorView` filling every remaining pixel of
onOpenChild(child) /// the window below it. No outer `ScrollView` here on purpose: each
} label: { /// pane already scrolls itself, and nesting that inside another
DocumentRowView(document: child) /// unbounded scroll container is exactly what was capping both panes
} /// at a fixed height instead of spanning the window.
.buttonStyle(.plain) private var splitViewContent: some View {
.padding(.vertical, 4) VStack(alignment: .leading, spacing: 12) {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
.padding([.horizontal, .top])
if child.id != viewModel.children.last?.id { splitEditorView
Divider() .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
/// Left is a plain, unrendered raw-text editor (deliberately not
/// `NativeTextViewWrapper` just the literal Markdown source); right
/// is the same rich rendering used everywhere else in the app,
/// read-only, bound to the same `viewModel.text` so it updates live as
/// the left side is typed into.
///
/// Scroll position between the two panes is **not** synchronized the
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
/// private internal view hierarchy to find its scroll view (the package
/// exposes no scroll position/delegate hook at all), which is fragile
/// enough to break silently on a package update. Flagged as a known
/// follow-up, not attempted here.
private var splitEditorView: some View {
HSplitView {
TextEditor(text: $viewModel.text)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(8)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: false
)
.padding(8)
.frame(maxWidth: .infinity, alignment: .topLeading)
} }
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@ViewBuilder @ViewBuilder
@@ -309,9 +386,11 @@ struct DocumentReaderView: View {
Divider() Divider()
if viewModel.separateEditingEnabled {
Button(viewModel.isEditing ? "Done Editing" : "Edit") { Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() } Task { await viewModel.toggleEditing() }
} }
}
// Membership management now lives in DocumentShareSheet's "People // Membership management now lives in DocumentShareSheet's "People
// with access" section, alongside the share link same sheet, // with access" section, alongside the share link same sheet,
// same isShowingShareSheet state. // same isShowingShareSheet state.
@@ -10,7 +10,6 @@ final class DocumentReaderViewModel {
var text: String var text: String
var collectionId: String? var collectionId: String?
var isFullWidth = false var isFullWidth = false
var children: [OutlineDocument] = []
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
@@ -18,6 +17,38 @@ final class DocumentReaderViewModel {
var isSaving = false var isSaving = false
var saveErrorMessage: String? var saveErrorMessage: String?
/// Snapshot of the preference, set once via `.task` right after the
/// view appears (can't be read from `@Environment` inside the view's
/// own `init`) rather than a live binding to `SessionStore` matches
/// how `isFullWidth` etc. are already seeded from the document at init
/// rather than observed reactively. A change made in Settings while a
/// document is already open takes effect the next document opened, not
/// mid-session; an acceptable tradeoff for how rarely this gets
/// toggled versus the complexity of threading a live preference
/// reference through every reader instance.
var separateEditingEnabled: Bool
/// The single source of truth the view reads for both "show the title
/// field" and "is the text view editable" when separate editing is
/// off there's no Edit/Done mode at all, the document is just always
/// editable (assuming permission; there's no per-document permission
/// field to pre-check against, so an unauthorized edit simply fails to
/// save rather than being blocked client-side up front).
var isEffectivelyEditable: Bool {
separateEditingEnabled ? isEditing : true
}
private var autosaveTask: Task<Void, Never>?
/// Tracks the last known-synced-with-the-server values so
/// `scheduleAutosave()` can no-op when called just because `text`/
/// `title` were reassigned *from* a server response (initial load, or
/// a completed save) rather than actually edited without this, every
/// document open in the always-editable mode would fire one pointless
/// autosave round-trip immediately, re-sending exactly what was just
/// received.
private var lastSyncedText: String
private var lastSyncedTitle: String
/// Recent viewers, `views.list` filtered to entries that actually have a /// Recent viewers, `views.list` filtered to entries that actually have a
/// `lastViewedAt` this is historical/aggregated view data, not live /// `lastViewedAt` this is historical/aggregated view data, not live
/// "viewing right now" presence (that needs the Hocuspocus collaboration /// "viewing right now" presence (that needs the Hocuspocus collaboration
@@ -38,7 +69,7 @@ final class DocumentReaderViewModel {
let documentId: String let documentId: String
private let apiClient: OutlineAPIClient private let apiClient: OutlineAPIClient
init(apiClient: OutlineAPIClient, document: OutlineDocument) { init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
self.apiClient = apiClient self.apiClient = apiClient
self.documentId = document.id self.documentId = document.id
self.title = document.title self.title = document.title
@@ -46,6 +77,9 @@ final class DocumentReaderViewModel {
self.text = document.text self.text = document.text
self.collectionId = document.collectionId self.collectionId = document.collectionId
self.isFullWidth = document.fullWidth ?? false self.isFullWidth = document.fullWidth ?? false
self.separateEditingEnabled = separateEditingEnabled
self.lastSyncedText = document.text
self.lastSyncedTitle = document.title
} }
/// The list endpoint's copy of a document isn't guaranteed to be the full, /// The list endpoint's copy of a document isn't guaranteed to be the full,
@@ -62,16 +96,11 @@ final class DocumentReaderViewModel {
text = full.text text = full.text
collectionId = full.collectionId collectionId = full.collectionId
isFullWidth = full.fullWidth ?? false isFullWidth = full.fullWidth ?? false
lastSyncedText = full.text
lastSyncedTitle = full.title
} catch { } catch {
errorMessage = "Couldn't load this document. Check your connection and try again." errorMessage = "Couldn't load this document. Check your connection and try again."
} }
children = (try? await apiClient.listDocuments(
collectionId: nil,
parentDocumentId: documentId,
offset: 0,
limit: 100
)) ?? []
} }
func loadViewers() async { func loadViewers() async {
@@ -146,20 +175,55 @@ final class DocumentReaderViewModel {
} }
} }
/// Turning editing off saves; turning it on is just a mode switch. /// Turning editing off saves; turning it on is just a mode switch. Only
/// meaningful when `separateEditingEnabled` the always-editable path
/// uses `scheduleAutosave()` instead.
func toggleEditing() async { func toggleEditing() async {
guard isEditing else { guard isEditing else {
isEditing = true isEditing = true
return return
} }
await save()
if saveErrorMessage == nil {
isEditing = false
}
}
/// Debounced save for the always-editable (separate editing off) path
/// cancels any pending save and starts a fresh countdown on every call,
/// so a save only actually fires once typing pauses, not on every
/// keystroke. Goes through the same `updateDocument` call the explicit
/// Done-button save uses, which is already offline-queue-aware
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
/// like any other edit instead of needing separate handling here.
func scheduleAutosave() {
guard text != lastSyncedText || title != lastSyncedTitle else { return }
autosaveTask?.cancel()
autosaveTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1.5))
guard let self, !Task.isCancelled else { return }
await self.save()
}
}
private func save() async {
isSaving = true isSaving = true
saveErrorMessage = nil saveErrorMessage = nil
defer { isSaving = false } defer { isSaving = false }
let sentTitle = title
let sentText = text
do { do {
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text)) let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
title = updated.title // Only reconcile with the server's response if nothing changed
text = updated.text // locally while the request was in flight otherwise this
isEditing = false // would clobber keystrokes typed during a debounced autosave's
// round trip. Whatever's newer goes out on the next autosave
// cycle regardless, since `scheduleAutosave()` keeps getting
// re-triggered by continued typing.
if title == sentTitle { title = updated.title }
if text == sentText { text = updated.text }
lastSyncedTitle = sentTitle
lastSyncedText = sentText
} catch { } catch {
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.") saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
} }
+12
View File
@@ -19,6 +19,7 @@ struct OutpostApp: App {
#if os(macOS) #if os(macOS)
@State private var isShowingLogoutConfirmation = false @State private var isShowingLogoutConfirmation = false
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
#endif #endif
var body: some Scene { var body: some Scene {
@@ -60,6 +61,17 @@ struct OutpostApp: App {
} }
.disabled(!session.isSignedIn) .disabled(!session.isSignedIn)
} }
// Settings Editor Command Palette gates this disabled
// (not just a no-op) when the user's turned it off, matching
// how Settings/Log Out already disable rather than silently
// do nothing.
CommandGroup(after: .newItem) {
Button("Command Palette…") {
navigation.isShowingCommandPalette = true
}
.keyboardShortcut("k")
.disabled(!session.isSignedIn || !isCommandPaletteEnabled)
}
} }
#endif #endif
+7 -3
View File
@@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
/// explicitly built yet. Content lands section by section. /// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable { enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours) // General (ours)
case appearance, offlineSync, advanced, about case appearance, editor, offlineSync, advanced, about
// Account // Account
case profile, preferences, notifications, passkeys, apiAccess case profile, preferences, notifications, passkeys, apiAccess
@@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var category: SettingsCategory { var category: SettingsCategory {
switch self { switch self {
case .appearance, .offlineSync, .advanced, .about: case .appearance, .editor, .offlineSync, .advanced, .about:
return .general return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess: case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account return .account
@@ -57,6 +57,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var title: String { var title: String {
switch self { switch self {
case .appearance: return "Appearance" case .appearance: return "Appearance"
case .editor: return "Editor"
case .offlineSync: return "Offline & Sync" case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced" case .advanced: return "Advanced"
case .about: return "About" case .about: return "About"
@@ -85,6 +86,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var icon: String { var icon: String {
switch self { switch self {
case .appearance: return "paintbrush" case .appearance: return "paintbrush"
case .editor: return "square.split.2x1"
case .offlineSync: return "arrow.triangle.2.circlepath" case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver" case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle" case .about: return "info.circle"
@@ -115,7 +117,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
/// specified and built. /// specified and built.
var isImplemented: Bool { var isImplemented: Bool {
switch self { switch self {
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess: case .appearance, .editor, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true return true
default: default:
return false return false
@@ -134,4 +136,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
final class AppNavigation { final class AppNavigation {
var isShowingSettings = false var isShowingSettings = false
var selectedSettingsSection: SettingsSection? = .appearance var selectedSettingsSection: SettingsSection? = .appearance
/// K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
var isShowingCommandPalette = false
} }
+33
View File
@@ -6,6 +6,13 @@ import OutlineKit
@Observable @Observable
final class SessionStore { final class SessionStore {
private static let serverURLDefaultsKey = "outline.serverURL" private static let serverURLDefaultsKey = "outline.serverURL"
/// Preferences now drive real editor behavior (separate editing, etc.),
/// not just a settings screen they need to survive a cold launch with
/// no network, not just live in memory from the last successful fetch.
/// Still read-only while offline (Settings already gates every toggle
/// on `isEffectivelyOnline`) this only makes the *last known* values
/// available, never lets them be changed without a server round-trip.
private static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring private let tokenStore: TokenStoring
private let defaults: UserDefaults private let defaults: UserDefaults
@@ -47,6 +54,7 @@ final class SessionStore {
if hasToken, let storedServerURL { if hasToken, let storedServerURL {
isSignedIn = true isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore) (apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else { } else {
// Keychain and the sandboxed UserDefaults container don't // Keychain and the sandboxed UserDefaults container don't
// always survive together a Keychain item written by an // always survive together a Keychain item written by an
@@ -70,6 +78,24 @@ final class SessionStore {
isSignedIn = true isSignedIn = true
} }
/// `static` (not an instance method) so `init` can call it before every
/// stored property has a value same reason `makeAPIClient` is static.
private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? {
guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil }
return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data)
}
/// `nil` clears the cache instead of writing a `null` happens whenever
/// a fresh fetch legitimately comes back with no preferences set, so a
/// stale cached value from a previous account/state can't linger.
private func cachePreferences(_ preferences: OutlineUserPreferences?) {
guard let preferences, let data = try? JSONEncoder().encode(preferences) else {
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
return
}
defaults.set(data, forKey: Self.userPreferencesDefaultsKey)
}
private static func makeAPIClient( private static func makeAPIClient(
serverURL: URL, serverURL: URL,
tokenStore: TokenStoring, tokenStore: TokenStoring,
@@ -99,6 +125,11 @@ final class SessionStore {
teamAvatarURL = nil teamAvatarURL = nil
apiClient = nil apiClient = nil
cachingClient = nil cachingClient = nil
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
// Same key `ContentView_macOS` persists "Remember previous
// location" under cleared here too so switching accounts/servers
// can't restore a stale location that belongs to a different sign-in.
defaults.removeObject(forKey: "outline.lastLocation")
} }
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this /// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
@@ -119,6 +150,7 @@ final class SessionStore {
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language userLanguage = user.language
userPreferences = user.preferences userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings userNotificationSettings = user.notificationSettings
} }
@@ -132,6 +164,7 @@ final class SessionStore {
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language userLanguage = user.language
userPreferences = user.preferences userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings userNotificationSettings = user.notificationSettings
teamName = team.name teamName = team.name
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }