10 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 21e81feed7 fix(settings): stop non-About sections from vertically centering
.leading as an Alignment means horizontally-leading but vertically
centered, not top-left — the minHeight fix for centering About ended
up vertically centering every other section too inside that same tall
box. .topLeading is what was actually meant; About still centers
correctly since its own .frame(maxHeight: .infinity, alignment: .center)
fills and self-centers within whatever space it's given regardless.
2026-08-15 01:49:05 +01:00
Puranjay Savar Mattas 8b4c5852ed feat(offline): support creating documents offline
createDocument now queues and syncs like the other offline-capable
writes, closing the gap deliberately left open earlier this session.
Synthesizes a pending-<uuid> document (same placeholder scheme as
pin/star/subscribe), caches it so it's immediately readable/openable,
and queues the real create. On sync, the server's real document
replaces the placeholder in the cache — no dead orphan entries.

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

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

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

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

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

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

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

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

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

Sidebar: replaced the old passive "Offline — showing cached content"
banner for a real dropped connection with an actionable prompt
(OfflineConnectionPromptBanner, styled like the existing
RemoteChangesBanner) offering to turn Offline Mode on — doesn't change
any behavior on its own, same as RemoteChangesBanner never auto-
refreshes. The informational banner still shows once Offline Mode is
actually on (manually, or via this prompt).
2026-08-15 01:08:03 +01:00
Puranjay Savar Mattas 1ce2aced90 fix(settings): red Clear Cache button, confirm when it's done
role: .destructive alone wasn't rendering red on its own — explicit
.buttonStyle(.borderedProminent).tint(.red), matching how other
destructive actions in the app (DocumentShareSheet's Revoke) had to
be styled explicitly too. Also shows a transient "Cleared" checkmark
for 2s after finishing, same timed-reset pattern as the share sheet's
copy-link feedback, since the storage count updating alone was easy
to miss.
2026-08-15 01:01:19 +01:00
11 changed files with 396 additions and 90 deletions
@@ -104,6 +104,27 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// MARK: - Queueable writes // MARK: - Queueable writes
/// The one exception to "no new tree structure while offline"
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
/// pin/star/subscribe), caches it so it's immediately readable/editable,
/// and queues the real create. On sync, the server-assigned id replaces
/// the placeholder in the cache; anything still referencing the old id
/// directly (a currently-open reader, most likely) won't follow that
/// rename automatically a known, narrow limitation, not something this
/// pass tries to solve generally.
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.createDocument(request)
await cacheDocument(result)
return result
} catch {
return await queueDocumentCreate(request)
}
}
return await queueDocumentCreate(request)
}
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
@@ -214,10 +235,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.searchDocumentTitles(request) try await live.searchDocumentTitles(request)
} }
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
try await live.createDocument(request)
}
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
try await live.templatizeDocument(request) try await live.templatizeDocument(request)
} }
@@ -409,6 +426,17 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// MARK: - Helpers // MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T { private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
// Manual offline mode means "skip the network entirely," not just
// "prefer it" without this check, a read would still hit `live`
// (and succeed, showing content beyond whatever's cached) any time
// the device actually had a connection, defeating the point of
// deliberately testing/working as if offline.
if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
return cached
}
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
do { do {
let result = try await fetch() let result = try await fetch()
if let data = try? encoder.encode(result) { if let data = try? encoder.encode(result) {
@@ -456,6 +484,30 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
return await cache.removeOperation(id: id) return await cache.removeOperation(id: id)
} }
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
let pendingId = "pending-\(UUID().uuidString)"
let now = Date()
let synthesized = OutlineDocument(
id: pendingId,
title: request.title,
text: request.text,
emoji: nil,
collectionId: request.collectionId,
parentDocumentId: request.parentDocumentId,
url: "/doc/\(pendingId)",
revision: nil,
fullWidth: nil,
createdAt: now,
updatedAt: now,
publishedAt: request.publish ? now : nil,
archivedAt: nil,
deletedAt: nil
)
await cacheDocument(synthesized)
await enqueue(.createDocument, payload: request, id: pendingId)
return synthesized
}
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument { private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"), guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else { let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
@@ -479,6 +531,26 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
deletedAt: base.deletedAt deletedAt: base.deletedAt
) )
await cacheDocument(merged) await cacheDocument(merged)
// Still-unsynced create for this exact document fold the edit into
// the pending create's payload instead of queuing a separate update.
// A separate update would target `merged.id`, which is still the
// `pending-*` placeholder at replay time; the server has never heard
// of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"),
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
let resolvedCreate = CreateDocumentRequest(
title: merged.title,
text: merged.text,
collectionId: createRequest.collectionId,
parentDocumentId: createRequest.parentDocumentId,
publish: createRequest.publish
)
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
return merged
}
// Fully resolved (not the original partial request) so replaying just // Fully resolved (not the original partial request) so replaying just
// this one queued operation reproduces `merged` exactly that's what // this one queued operation reproduces `merged` exactly that's what
// makes coalescing a second edit onto the same queued id safe. // makes coalescing a second edit onto the same queued id safe.
@@ -538,6 +610,13 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
)) ))
} }
switch kind { switch kind {
case .createDocument:
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request)
await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument: case .updateDocument:
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload) let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request) let result = try await live.updateDocument(request)
@@ -31,6 +31,16 @@ public actor OfflineCacheStore {
return try? modelContext.fetch(descriptor).first?.payload return try? modelContext.fetch(descriptor).first?.payload
} }
/// Used to drop a temporary `pending-*` document's cache entry once a
/// queued create syncs and the server hands back the real id the
/// placeholder key would otherwise sit around as a dead orphan forever.
public func removeCacheEntry(forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
modelContext.delete(existing)
try? modelContext.save()
}
public func itemCount() -> Int { public func itemCount() -> Int {
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0 (try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
} }
@@ -32,6 +32,7 @@ public struct FullSyncSummary: Sendable {
/// replay later. Deliberately a small, explicit set see its doc comment /// replay later. Deliberately a small, explicit set see its doc comment
/// for what's excluded and why. /// for what's excluded and why.
enum PendingOperationKind: String, Codable, Sendable { enum PendingOperationKind: String, Codable, Sendable {
case createDocument
case updateDocument case updateDocument
case updateCollection case updateCollection
case createPin case createPin
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct CreateDocumentRequest: Encodable, Sendable { public struct CreateDocumentRequest: Codable, Sendable {
public let title: String public let title: String
public let text: String public let text: String
public let collectionId: String public let collectionId: String
@@ -13,6 +13,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)? var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
var deletePinHandler: (@Sendable (String) async throws -> Void)? var deletePinHandler: (@Sendable (String) async throws -> Void)?
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])? var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
@@ -30,7 +31,10 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() } func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() } func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() } func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
guard let handler = createDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
guard let handler = updateDocumentHandler else { throw NotStubbed() } guard let handler = updateDocumentHandler else { throw NotStubbed() }
@@ -326,6 +330,39 @@ final class CachingOutlineAPIClientTests: XCTestCase {
XCTAssertTrue(pin.id.hasPrefix("pending-")) XCTAssertTrue(pin.id.hasPrefix("pending-"))
} }
/// Regression test: manual offline mode used to only gate writes
/// reads (listCollections, documentInfo, etc.) still hit `live` first
/// any time the device actually had a connection, silently defeating
/// "skip the network entirely" for the one thing that mattered most:
/// the sidebar showing more than what was actually cached.
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
let stub = StubOutlineAPIClient()
let cachedCollection = makeCollection(id: "col-1")
var liveCallCount = 0
stub.listCollectionsHandler = { _, _ in
liveCallCount += 1
return [cachedCollection]
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
// Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(firstResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1)
// Flip manual offline mode on, still "connected" (stub would happily
// answer) the live call must not be attempted at all.
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(secondResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
}
func testCacheStorageSummaryReflectsCachedItems() async throws { func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() } stub.documentInfoHandler = { _ in self.makeDocument() }
@@ -385,4 +422,79 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let cachedDoc = try await sut.documentInfo(id: "doc-2") let cachedDoc = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedDoc.id, "doc-2") XCTAssertEqual(cachedDoc.id, "doc-2")
} }
// MARK: - Offline document creation
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
XCTAssertTrue(created.id.hasPrefix("pending-"))
XCTAssertEqual(created.title, "New Doc")
XCTAssertEqual(created.text, "hello")
// It's immediately readable, same as any other cached document.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "New Doc")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "createDocument")
}
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
)
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
XCTAssertEqual(pending.first?.kind, "createDocument")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "Real Title")
XCTAssertEqual(reopened.text, "Real body")
}
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
stub.createDocumentHandler = { _ in realDocument }
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
// The real document is now readable under its real id...
stub.documentInfoHandler = { _ in throw StubTransportError() }
let byRealId = try await sut.documentInfo(id: "real-server-id")
XCTAssertEqual(byRealId.id, "real-server-id")
// ...and the placeholder is gone rather than left as a dead orphan.
do {
_ = try await sut.documentInfo(id: created.id)
XCTFail("Expected the placeholder cache entry to have been removed")
} catch {
// expected nothing left under the old id
}
}
} }
+34 -29
View File
@@ -21,6 +21,7 @@ struct SettingsView: View {
@State private var pendingOperations: [PendingOperationSummary] = [] @State private var pendingOperations: [PendingOperationSummary] = []
@State private var isSyncing = false @State private var isSyncing = false
@State private var isClearingCache = false @State private var isClearingCache = false
@State private var didClearCache = false
@State private var lastFullSyncSummary: FullSyncSummary? @State private var lastFullSyncSummary: FullSyncSummary?
@State private var lastFlushSummary: SyncFlushSummary? @State private var lastFlushSummary: SyncFlushSummary?
@@ -35,10 +36,21 @@ struct SettingsView: View {
} }
var body: some View { var body: some View {
// GeometryReader + `minHeight` (not `maxHeight`) is the actual fix for
// "center short content inside a ScrollView" a ScrollView proposes
// effectively unbounded height to its content, so `maxHeight: .infinity`
// alone just resolves to the content's own intrinsic size and does
// nothing; forcing a `minHeight` equal to the real viewport height is
// what gives `aboutDetail`'s own centered alignment somewhere to
// actually center within. Harmless for the other (already
// top/leading-aligned) sections short ones just get blank space
// below, same as before.
GeometryReader { geometry in
ScrollView { ScrollView {
sectionDetail sectionDetail
.padding(28) .padding(28)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background) .background(.background)
@@ -158,12 +170,6 @@ struct SettingsView: View {
} }
} }
Divider()
.frame(maxWidth: 480)
storageRow
.frame(maxWidth: 480, alignment: .leading)
Divider() Divider()
.frame(maxWidth: 480) .frame(maxWidth: 480)
@@ -172,25 +178,6 @@ struct SettingsView: View {
} }
} }
private var storageRow: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Cache Storage")
.font(.subheadline.weight(.medium))
if let storageSummary {
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Clearing the cache is in Advanced Options.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var pendingOperationsRow: some View { private var pendingOperationsRow: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
@@ -288,9 +275,14 @@ struct SettingsView: View {
.frame(maxWidth: 480) .frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack(spacing: 10) {
Text("Clear All Cache") Text("Clear All Cache")
Spacer() Spacer()
if didClearCache {
Label("Cleared", systemImage: "checkmark")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(role: .destructive) { Button(role: .destructive) {
Task { await clearCache() } Task { await clearCache() }
} label: { } label: {
@@ -300,6 +292,13 @@ struct SettingsView: View {
Text("Clear") Text("Clear")
} }
} }
.buttonStyle(.borderedProminent)
.tint(.red)
}
if let storageSummary {
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
.font(.caption)
.foregroundStyle(.secondary)
} }
Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.") Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.")
.font(.caption2) .font(.caption2)
@@ -352,11 +351,12 @@ struct SettingsView: View {
// MARK: - About // MARK: - About
private var aboutDetail: some View { private var aboutDetail: some View {
VStack(alignment: .leading, spacing: 16) { VStack(spacing: 16) {
sectionHeader sectionHeader
AboutInfoView() AboutInfoView()
.frame(maxWidth: 420, alignment: .leading) .frame(maxWidth: 420)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} }
// MARK: - Helpers // MARK: - Helpers
@@ -410,6 +410,11 @@ struct SettingsView: View {
defer { isClearingCache = false } defer { isClearingCache = false }
await cachingClient.clearCache() await cachingClient.clearCache()
await refreshSyncState() await refreshSyncState()
didClearCache = true
Task {
try? await Task.sleep(for: .seconds(2))
didClearCache = false
}
} }
} }
#endif #endif
@@ -40,26 +40,64 @@ struct ContentView_macOS: View {
} }
var body: some View { var body: some View {
NavigationSplitView { // A totally separate top-level branch, not content swapped inside
Group { // one persistent `NavigationSplitView` that was tried first (an
// `if/else` inside a single split view, then an explicit `.id()` on
// just the detail pane) and neither reliably replaced the detail
// pane's content on macOS: `NavigationSplitView` bridges to
// `NSSplitViewController`, and swapping a `NavigationStack` with
// real push history for a plain view inside one persisting instance
// doesn't propagate the way plain SwiftUI identity rules would
// suggest. A different `if` branch is a genuinely different view
// hierarchy, so there's no existing split view instance for AppKit
// to get confused about reusing.
if navigation.isShowingSettings { if navigation.isShowingSettings {
settingsContent
} else {
mainContent
}
}
private var settingsContent: some View {
NavigationSplitView {
SettingsSidebarList( SettingsSidebarList(
selection: selectedSettingsSectionBinding, selection: selectedSettingsSectionBinding,
onDone: { navigation.isShowingSettings = false } onDone: { navigation.isShowingSettings = false }
) )
} else { .navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: {
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
}
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .navigation) {
HStack(spacing: 6) {
Image(systemName: "gearshape.fill")
Text("Settings")
}
.font(.headline)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
}
private var mainContent: some View {
NavigationSplitView {
VStack(spacing: 0) { VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery) SidebarSearchField(text: $globalSearchQuery)
Divider() Divider()
if isOfflineModeEnabled || !session.networkMonitor.isOnline { if isOfflineModeEnabled {
OfflineBanner(isManual: isOfflineModeEnabled) OfflineBanner(isManual: true)
Divider()
} else if !session.networkMonitor.isOnline {
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
Divider() Divider()
} }
sidebar sidebar
AccountFooter() AccountFooter()
} }
}
}
.navigationSplitViewColumnWidth(min: 220, ideal: 260) .navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: { } detail: {
detail detail
@@ -97,7 +135,7 @@ struct ContentView_macOS: View {
// `CollectionOverviewView`, so on Home it was a dead end: a // `CollectionOverviewView`, so on Home it was a dead end: a
// user could click it, type, and nothing would happen. The // user could click it, type, and nothing would happen. The
// sidebar's global search already covers "search everything." // sidebar's global search already covers "search everything."
if !navigation.isShowingSettings && !(isShowingHome && documentPath.isEmpty) { if !(isShowingHome && documentPath.isEmpty) {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
contextualSearchField contextualSearchField
} }
@@ -167,12 +205,7 @@ struct ContentView_macOS: View {
@ViewBuilder @ViewBuilder
private var leadingToolbarContent: some View { private var leadingToolbarContent: some View {
Group { Group {
if navigation.isShowingSettings { if !trimmedGlobalQuery.isEmpty {
HStack(spacing: 6) {
Image(systemName: "gearshape.fill")
Text("Settings")
}
} else if !trimmedGlobalQuery.isEmpty {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
Text("Search") Text("Search")
@@ -251,7 +284,6 @@ struct ContentView_macOS: View {
globalSearchQuery = "" globalSearchQuery = ""
selectedCollection = collection selectedCollection = collection
isContextualSearchExpanded = true isContextualSearchExpanded = true
navigation.isShowingSettings = false
Task { @MainActor in Task { @MainActor in
isContextualSearchFocused = true isContextualSearchFocused = true
} }
@@ -262,13 +294,11 @@ struct ContentView_macOS: View {
/// hierarchy immediately rather than just the leaf. /// hierarchy immediately rather than just the leaf.
private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) { private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) {
selectedCollection = collection selectedCollection = collection
navigation.isShowingSettings = false
replaceDocumentPath(with: chain) replaceDocumentPath(with: chain)
} }
/// Flat-list / search-result clicks: no known ancestors, single-level push. /// Flat-list / search-result clicks: no known ancestors, single-level push.
private func openDocument(_ document: OutlineDocument) { private func openDocument(_ document: OutlineDocument) {
navigation.isShowingSettings = false
replaceDocumentPath(with: [document]) replaceDocumentPath(with: [document])
} }
@@ -287,14 +317,7 @@ struct ContentView_macOS: View {
@ViewBuilder @ViewBuilder
private var detail: some View { private var detail: some View {
if navigation.isShowingSettings { if let apiClient = session.apiClient {
// Deliberately not a `NavigationStack` destination or a separate
// window it's swapped into the same detail pane Home/
// collections use, alongside the real sidebar (now showing
// `SettingsSidebarList` instead of the collections tree) rather
// than a page with its own nested mini sidebar.
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
} else if let apiClient = session.apiClient {
// A fresh NavigationStack per collection (or when entering/leaving // A fresh NavigationStack per collection (or when entering/leaving
// search), so switching either also clears any pushed document. // search), so switching either also clears any pushed document.
NavigationStack(path: $documentPath) { NavigationStack(path: $documentPath) {
@@ -12,6 +12,7 @@ import OutlineKit
struct DocumentReaderView: View { struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore @Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var viewModel: DocumentReaderViewModel @State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
@@ -56,6 +57,15 @@ struct DocumentReaderView: View {
self.onDocumentCreated = onDocumentCreated self.onDocumentCreated = onDocumentCreated
} }
/// New Document, editing, pin/star/subscribe, and Full Width all queue
/// and sync later (see `CachingOutlineAPIClient`) everything else here
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
/// history, insights, export) hits the server directly with no offline
/// path, so it's disabled rather than left to fail confusingly on tap.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -82,6 +92,7 @@ struct DocumentReaderView: View {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $viewModel.text, text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEditing isEditable: viewModel.isEditing
) )
@@ -110,7 +121,8 @@ struct DocumentReaderView: View {
} label: { } label: {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
} }
.help("Share") .help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
.disabled(!isEffectivelyOnline)
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) { .popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
} }
@@ -235,7 +247,8 @@ struct DocumentReaderView: View {
viewModel.isPinned, viewModel.isPinned,
viewModel.isInsightsEnabled ?? false, viewModel.isInsightsEnabled ?? false,
viewModel.isFullWidth, viewModel.isFullWidth,
viewModel.isEditing viewModel.isEditing,
isEffectivelyOnline
].map(String.init).joined(separator: "-") ].map(String.init).joined(separator: "-")
} }
@@ -305,27 +318,33 @@ struct DocumentReaderView: View {
Button("Permissions…") { Button("Permissions…") {
isShowingShareSheet = true isShowingShareSheet = true
} }
.disabled(!isEffectivelyOnline)
Divider() Divider()
Button("Templatize") { Button("Templatize") {
Task { await templatize() } Task { await templatize() }
} }
.disabled(!isEffectivelyOnline)
Button("Duplicate") { Button("Duplicate") {
Task { await duplicate() } Task { await duplicate() }
} }
.disabled(!isEffectivelyOnline)
Button("Unpublish") { Button("Unpublish") {
isShowingUnpublishConfirmation = true isShowingUnpublishConfirmation = true
} }
.disabled(!isEffectivelyOnline)
Button("Archive…") { Button("Archive…") {
isShowingArchiveConfirmation = true isShowingArchiveConfirmation = true
} }
.disabled(!isEffectivelyOnline)
Divider() Divider()
Button("Move") { Button("Move") {
isShowingMoveSheet = true isShowingMoveSheet = true
} }
.disabled(!isEffectivelyOnline)
// Multipart file upload is its own subsystem deferred rather than // Multipart file upload is its own subsystem deferred rather than
// half-built here. // half-built here.
Button("Import Document…") {} Button("Import Document…") {}
@@ -342,9 +361,13 @@ struct DocumentReaderView: View {
Button("History") { Button("History") {
isShowingHistorySheet = true isShowingHistorySheet = true
} }
.disabled(!isEffectivelyOnline)
Button("Insights") { Button("Insights") {
isShowingInsightsSheet = true isShowingInsightsSheet = true
} }
.disabled(!isEffectivelyOnline)
// Present/Search in Document both read `documents.info`, which is
// read-through cached they work offline on whatever's cached.
Button("Present") { Button("Present") {
isShowingPresentSheet = true isShowingPresentSheet = true
} }
@@ -354,6 +377,7 @@ struct DocumentReaderView: View {
Button("Download") { Button("Download") {
Task { await download() } Task { await download() }
} }
.disabled(!isEffectivelyOnline)
Button("Copy") { Button("Copy") {
Task { await copyMarkdown() } Task { await copyMarkdown() }
} }
@@ -370,6 +394,7 @@ struct DocumentReaderView: View {
get: { viewModel.isInsightsEnabled ?? false }, get: { viewModel.isInsightsEnabled ?? false },
set: { _ in Task { await toggleInsights() } } set: { _ in Task { await toggleInsights() } }
)) ))
.disabled(!isEffectivelyOnline)
// Confirmed against a live server: there's no per-document embeds // Confirmed against a live server: there's no per-document embeds
// field. Only a workspace-level setting exists, and that's not // field. Only a workspace-level setting exists, and that's not
// reachable via the API either (no `team.update` endpoint in the // reachable via the API either (no `team.update` endpoint in the
@@ -386,6 +411,7 @@ struct DocumentReaderView: View {
Button("Delete…", role: .destructive) { Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true isShowingDeleteConfirmation = true
} }
.disabled(!isEffectivelyOnline)
} }
private func star() async { private func star() async {
@@ -0,0 +1,30 @@
#if os(macOS)
import SwiftUI
/// Shown in the sidebar when the network is actually down and the user
/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change
/// any fetch behavior on its own reads still try live and fall back to
/// cache the normal way (see `CachingOutlineAPIClient`) until the user
/// actually taps the button here, same as `RemoteChangesBanner` never
/// auto-refreshes on their behalf either.
struct OfflineConnectionPromptBanner: View {
let onEnableOfflineMode: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundStyle(.orange)
Text("No connection")
.font(.callout)
Spacer(minLength: 8)
Button("Turn On Offline Mode", action: onEnableOfflineMode)
.buttonStyle(.borderedProminent)
.tint(.orange)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
+15 -7
View File
@@ -57,25 +57,33 @@ final class HomeViewModel {
/// Fetches fresh pinned docs and the current tab's documents to compare /// Fetches fresh pinned docs and the current tab's documents to compare
/// against what's displayed, without replacing either. Bails silently /// against what's displayed, without replacing either. Bails silently
/// on a fetch failure rather than treating it as "changed" a /// on a fetch failure rather than treating it as "changed" a
/// transient network hiccup shouldn't pop the refresh banner. /// transient network hiccup (or, offline, `listPins` failing outright
/// it isn't one of the cached endpoints) shouldn't pop the refresh
/// banner. This needs the *throwing* pinned-fetch specifically: the
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
/// which used to read here as "pins changed" against whatever was
/// already displayed and falsely popped the banner on every offline
/// poll.
func checkForRemoteChanges(tab: HomeTab) async { func checkForRemoteChanges(tab: HomeTab) async {
async let freshPinned = fetchPinned() async let freshPinnedTask = fetchPinnedThrowing()
guard let freshTab = try? await fetch(tab: tab) else { return } guard let freshTab = try? await fetch(tab: tab) else { return }
let pinned = await freshPinned guard let pinned = try? await freshPinnedTask else { return }
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments) if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) { || Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
hasRemoteChanges = true hasRemoteChanges = true
} }
} }
private func fetchPinned() async -> [OutlineDocument] {
(try? await fetchPinnedThrowing()) ?? []
}
/// `pins.list` only returns pin records, not the documents themselves /// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated /// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable /// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar. /// where it wouldn't be in the sidebar.
private func fetchPinned() async -> [OutlineDocument] { private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else { let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
return []
}
var documents: [OutlineDocument] = [] var documents: [OutlineDocument] = []
for pin in pins { for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) { if let document = try? await apiClient.documentInfo(id: pin.documentId) {
+20 -8
View File
@@ -6,6 +6,18 @@ struct RootView: View {
@State private var welcomeName: String? @State private var welcomeName: String?
@State private var starStore = StarStore() @State private var starStore = StarStore()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Real connectivity is only half of "can talk to the server" the
/// manual Offline Mode toggle is the other half. Syncing needs to
/// resume on either one clearing, not just a real reconnect: turning
/// the toggle off while the network had been up the whole time never
/// changes `networkMonitor.isOnline`, so keying the flush task off that
/// alone left pending operations stuck until the next real network
/// blip.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View { var body: some View {
ZStack { ZStack {
@@ -33,13 +45,13 @@ struct RootView: View {
starStore.reset() starStore.reset()
} }
} }
// Replay whatever queued up while offline the moment the network's // Replay whatever queued up while offline the moment either signal
// back no need to wait for the user to open Settings and hit Retry. // clears a real reconnect, or the user turning Offline Mode back
// Also catches Full Local Sync back up immediately on reconnect, // off no need to wait for the user to open Settings and hit Retry.
// rather than leaving it to wait out the rest of the periodic loop // Also catches Full Local Sync back up immediately, rather than
// below. // leaving it to wait out the rest of the periodic loop below.
.task(id: session.networkMonitor.isOnline) { .task(id: isEffectivelyOnline) {
guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return } guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
_ = await cachingClient.flushPendingOperations() _ = await cachingClient.flushPendingOperations()
if isFullLocalSyncEnabled { if isFullLocalSyncEnabled {
_ = await cachingClient.performFullSync() _ = await cachingClient.performFullSync()
@@ -55,7 +67,7 @@ struct RootView: View {
.task(id: isFullLocalSyncEnabled) { .task(id: isFullLocalSyncEnabled) {
guard isFullLocalSyncEnabled else { return } guard isFullLocalSyncEnabled else { return }
while !Task.isCancelled { while !Task.isCancelled {
if session.networkMonitor.isOnline, let cachingClient = session.cachingClient { if isEffectivelyOnline, let cachingClient = session.cachingClient {
_ = await cachingClient.performFullSync() _ = await cachingClient.performFullSync()
} }
try? await Task.sleep(for: .seconds(1200)) try? await Task.sleep(for: .seconds(1200))