Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195dc2cc59
|
||
|
|
bef194d493
|
||
|
|
bdde8642f7
|
||
|
|
1229678c00
|
@@ -433,6 +433,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
/// existing cached-read methods already do the caching as a side effect,
|
||||
/// this just has to drive the walk and separately cache each document by
|
||||
/// id (`listDocuments`'s cache key is the list, not the individual doc).
|
||||
///
|
||||
/// 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 {
|
||||
var documentsCount = 0
|
||||
var errors: [String] = []
|
||||
@@ -457,28 +466,64 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
}
|
||||
|
||||
for collection in collections {
|
||||
var offset = 0
|
||||
let limit = 100
|
||||
while true {
|
||||
let documents: [OutlineDocument]
|
||||
do {
|
||||
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
|
||||
} catch {
|
||||
errors.append("\(collection.name): \(errorDescription(error))")
|
||||
break
|
||||
}
|
||||
for document in documents {
|
||||
await cacheDocument(document)
|
||||
}
|
||||
documentsCount += documents.count
|
||||
guard documents.count == limit else { break }
|
||||
offset += limit
|
||||
}
|
||||
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
|
||||
let limit = 100
|
||||
while true {
|
||||
let documents: [OutlineDocument]
|
||||
do {
|
||||
documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
|
||||
} catch {
|
||||
errors.append("\(collectionName): \(errorDescription(error))")
|
||||
break
|
||||
}
|
||||
for document in documents {
|
||||
await cacheDocument(document)
|
||||
count += 1
|
||||
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
|
||||
count += childResult.count
|
||||
errors.append(contentsOf: childResult.errors)
|
||||
}
|
||||
guard documents.count == limit else { break }
|
||||
offset += limit
|
||||
}
|
||||
return (count, errors)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
||||
|
||||
@@ -31,6 +31,15 @@ public actor OfflineCacheStore {
|
||||
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
|
||||
/// queued create syncs and the server hands back the real id — the
|
||||
/// placeholder key would otherwise sit around as a dead orphan forever.
|
||||
|
||||
@@ -421,8 +421,13 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||
stub.listDocumentsHandler = { _, _, offset, _ in
|
||||
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
||||
// Must return empty for any non-nil parentDocumentId (no children) —
|
||||
// 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 sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
||||
@@ -437,6 +442,34 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
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
|
||||
|
||||
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||
|
||||
@@ -14,6 +14,9 @@ struct SettingsView: View {
|
||||
|
||||
@Environment(SessionStore.self) private var session
|
||||
@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("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||
@AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false
|
||||
@@ -92,6 +95,7 @@ struct SettingsView: View {
|
||||
private var sectionDetail: some View {
|
||||
switch section {
|
||||
case .appearance: appearanceDetail
|
||||
case .editor: editorDetail
|
||||
case .profile: profileDetail
|
||||
case .preferences: preferencesDetail
|
||||
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
|
||||
|
||||
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(AppNavigation.self) private var navigation
|
||||
@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
|
||||
/// means, so this starts `true` rather than auto-selecting the first
|
||||
/// 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
|
||||
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
||||
@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 {
|
||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -147,6 +153,38 @@ struct ContentView_macOS: View {
|
||||
if newValue != nil {
|
||||
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: [])
|
||||
}
|
||||
|
||||
// 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
|
||||
private var contextualSearchField: some View {
|
||||
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
|
||||
|
||||
@@ -13,6 +13,9 @@ struct DocumentReaderView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(StarStore.self) private var starStore
|
||||
@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
|
||||
let apiClient: OutlineAPIClient
|
||||
@@ -51,6 +54,11 @@ struct DocumentReaderView: View {
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
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))
|
||||
self.onOpenChild = onOpenChild
|
||||
self.onDeleted = onDeleted
|
||||
@@ -66,44 +74,26 @@ struct DocumentReaderView: View {
|
||||
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 {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if viewModel.isEditing {
|
||||
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 {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEditing
|
||||
)
|
||||
|
||||
if !viewModel.children.isEmpty {
|
||||
childrenSection
|
||||
}
|
||||
}
|
||||
Group {
|
||||
if canShowSplitView {
|
||||
splitViewContent
|
||||
} else {
|
||||
scrollingReaderContent
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if viewModel.isLoading && !viewModel.text.isEmpty {
|
||||
@@ -127,16 +117,24 @@ struct DocumentReaderView: View {
|
||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
} label: {
|
||||
if viewModel.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(viewModel.isEditing ? "Done" : "Edit")
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
} label: {
|
||||
if viewModel.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(viewModel.isEditing ? "Done" : "Edit")
|
||||
}
|
||||
}
|
||||
.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…")
|
||||
}
|
||||
.disabled(viewModel.isSaving)
|
||||
|
||||
Button {
|
||||
isShowingNewDocumentSheet = true
|
||||
@@ -160,6 +158,17 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
await viewModel.loadPinAndSubscriptionState()
|
||||
}
|
||||
@@ -272,31 +281,99 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var childrenSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Text("Sub-documents")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(viewModel.children) { child in
|
||||
Button {
|
||||
onOpenChild(child)
|
||||
} label: {
|
||||
DocumentRowView(document: child)
|
||||
/// Today's single-pane layout — page-level `ScrollView` wrapping title +
|
||||
/// content, used for the normal reading/editing view, and for every
|
||||
/// loading/error state regardless of Split View.
|
||||
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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if child.id != viewModel.children.last?.id {
|
||||
Divider()
|
||||
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 {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Split View's layout — title fixed at the top (not part of either
|
||||
/// scrolling pane), `splitEditorView` filling every remaining pixel of
|
||||
/// the window below it. No outer `ScrollView` here on purpose: each
|
||||
/// pane already scrolls itself, and nesting that inside another
|
||||
/// unbounded scroll container is exactly what was capping both panes
|
||||
/// at a fixed height instead of spanning the window.
|
||||
private var splitViewContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
.padding([.horizontal, .top])
|
||||
|
||||
splitEditorView
|
||||
.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
|
||||
private var menuContent: some View {
|
||||
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
||||
@@ -309,8 +386,10 @@ struct DocumentReaderView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
}
|
||||
}
|
||||
// Membership management now lives in DocumentShareSheet's "People
|
||||
// with access" section, alongside the share link — same sheet,
|
||||
|
||||
@@ -10,7 +10,6 @@ final class DocumentReaderViewModel {
|
||||
var text: String
|
||||
var collectionId: String?
|
||||
var isFullWidth = false
|
||||
var children: [OutlineDocument] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
@@ -18,6 +17,38 @@ final class DocumentReaderViewModel {
|
||||
var isSaving = false
|
||||
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
|
||||
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
||||
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
||||
@@ -38,7 +69,7 @@ final class DocumentReaderViewModel {
|
||||
let documentId: String
|
||||
private let apiClient: OutlineAPIClient
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
||||
self.apiClient = apiClient
|
||||
self.documentId = document.id
|
||||
self.title = document.title
|
||||
@@ -46,6 +77,9 @@ final class DocumentReaderViewModel {
|
||||
self.text = document.text
|
||||
self.collectionId = document.collectionId
|
||||
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,
|
||||
@@ -62,16 +96,11 @@ final class DocumentReaderViewModel {
|
||||
text = full.text
|
||||
collectionId = full.collectionId
|
||||
isFullWidth = full.fullWidth ?? false
|
||||
lastSyncedText = full.text
|
||||
lastSyncedTitle = full.title
|
||||
} catch {
|
||||
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 {
|
||||
@@ -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 {
|
||||
guard isEditing else {
|
||||
isEditing = true
|
||||
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
|
||||
saveErrorMessage = nil
|
||||
defer { isSaving = false }
|
||||
let sentTitle = title
|
||||
let sentText = text
|
||||
do {
|
||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
|
||||
title = updated.title
|
||||
text = updated.text
|
||||
isEditing = false
|
||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
|
||||
// Only reconcile with the server's response if nothing changed
|
||||
// locally while the request was in flight — otherwise this
|
||||
// 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 {
|
||||
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ struct OutpostApp: App {
|
||||
|
||||
#if os(macOS)
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
||||
#endif
|
||||
|
||||
var body: some Scene {
|
||||
@@ -60,6 +61,17 @@ struct OutpostApp: App {
|
||||
}
|
||||
.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
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
/// explicitly built yet. Content lands section by section.
|
||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
// General (ours)
|
||||
case appearance, offlineSync, advanced, about
|
||||
case appearance, editor, offlineSync, advanced, about
|
||||
|
||||
// Account
|
||||
case profile, preferences, notifications, passkeys, apiAccess
|
||||
@@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
|
||||
var category: SettingsCategory {
|
||||
switch self {
|
||||
case .appearance, .offlineSync, .advanced, .about:
|
||||
case .appearance, .editor, .offlineSync, .advanced, .about:
|
||||
return .general
|
||||
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return .account
|
||||
@@ -57,6 +57,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .appearance: return "Appearance"
|
||||
case .editor: return "Editor"
|
||||
case .offlineSync: return "Offline & Sync"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
@@ -85,6 +86,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .appearance: return "paintbrush"
|
||||
case .editor: return "square.split.2x1"
|
||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||
case .advanced: return "wrench.and.screwdriver"
|
||||
case .about: return "info.circle"
|
||||
@@ -115,7 +117,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
/// specified and built.
|
||||
var isImplemented: Bool {
|
||||
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
|
||||
default:
|
||||
return false
|
||||
@@ -134,4 +136,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
final class AppNavigation {
|
||||
var isShowingSettings = false
|
||||
var selectedSettingsSection: SettingsSection? = .appearance
|
||||
/// ⌘K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
|
||||
var isShowingCommandPalette = false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ import OutlineKit
|
||||
@Observable
|
||||
final class SessionStore {
|
||||
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 defaults: UserDefaults
|
||||
@@ -47,6 +54,7 @@ final class SessionStore {
|
||||
if hasToken, let storedServerURL {
|
||||
isSignedIn = true
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
userPreferences = Self.loadCachedPreferences(defaults: defaults)
|
||||
} else {
|
||||
// Keychain and the sandboxed UserDefaults container don't
|
||||
// always survive together — a Keychain item written by an
|
||||
@@ -70,6 +78,24 @@ final class SessionStore {
|
||||
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(
|
||||
serverURL: URL,
|
||||
tokenStore: TokenStoring,
|
||||
@@ -99,6 +125,11 @@ final class SessionStore {
|
||||
teamAvatarURL = nil
|
||||
apiClient = 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
|
||||
@@ -119,6 +150,7 @@ final class SessionStore {
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
cachePreferences(user.preferences)
|
||||
userNotificationSettings = user.notificationSettings
|
||||
}
|
||||
|
||||
@@ -132,6 +164,7 @@ final class SessionStore {
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
cachePreferences(user.preferences)
|
||||
userNotificationSettings = user.notificationSettings
|
||||
teamName = team.name
|
||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
|
||||
Reference in New Issue
Block a user