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