Drafts + Publish:
- New Home tab backed by documents.drafts (undocumented request shape
confirmed from a live network capture, not the OpenAPI spec).
- Reader toolbar menu now shows Publish... for an unpublished
document instead of an unconditional Unpublish (which could
previously be tapped on a draft at all). Publish opens a
MoveDocumentSheet-style collection/parent picker, pre-filled from
the draft's own collectionId/parentDocumentId when it already has
one. Publishing itself is documents.update(publish: true,
collectionId:) for the collection placement, plus a second
documents.move call only when a specific parent document was also
picked (documents.update has no parentDocumentId field).
- New Document defaults to Draft (collectionId/publish both now
optional on CreateDocumentRequest, previously collectionId was
required so a draft couldn't be created from this sheet at all).
Contextual entry points (right-click a collection/document) still
pre-fill that location, but now show a warning that doing so
auto-publishes.
- Fixed onDeleted only popping the reader's nav path without telling
the sidebar to refresh - Delete/Archive/Unpublish/Move all left the
sidebar showing stale state until an unrelated trigger (the 45s
poll, navigating away and back) happened to catch it up.
Comments:
- Replies (comments.create with parentCommentId, one level of nesting
same as Outline's own limit) and emoji reactions
(comments.add_reaction/remove_reaction, confirmed against Outline's
server source - not in the spec, and return {success: true} rather
than the updated comment, so a toggle refetches via comments.info
for the real post-toggle state) plus a document-level "new comment"
composer, since replying needs something to reply to.
- Inline anchor markers: a new engine-side mechanism
(CommentAnchorQuery/CommentAnchorRect/onCommentAnchorRectsChange)
resolves an anchored comment's anchorText to an on-screen rect via
the same viewRect utility the code-block copy button uses, kept in
sync on typing/resize/reflow the same way the code-block and image
positioning fixes earlier this session are. Renders as a thin blue
bar next to the commented text; tapping it opens the comments sheet
scrolled and highlighted to that thread. First-occurrence text
search only (Outline's API returns no position data, and no
prefix/suffix on read) - creating new anchored comments from this
app still isn't supported.
- Toolbar badge: tighter offset so the count doesn't clip past the
icon, caps at "10+".
467 lines
21 KiB
Swift
467 lines
21 KiB
Swift
#if os(macOS)
|
|
import SwiftUI
|
|
import OutlineKit
|
|
|
|
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.
|
|
@State private var isShowingHome = true
|
|
@State private var selectedCollection: OutlineCollection?
|
|
/// The real navigation stack, root to leaf — also the source of truth for
|
|
/// the toolbar breadcrumb, so the two can't drift out of sync.
|
|
@State private var documentPath: [OutlineDocument] = []
|
|
@State private var globalSearchQuery = ""
|
|
@State private var contextualSearchQuery = ""
|
|
@State private var isContextualSearchExpanded = false
|
|
@FocusState private var isContextualSearchFocused: Bool
|
|
/// Bumped whenever a document is created from somewhere with no direct
|
|
/// handle on the sidebar row it belongs under (the reader toolbar's and
|
|
/// Home's "New Document" buttons) — every expanded sidebar row reloads
|
|
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
|
@State private var documentsChangedToken = 0
|
|
/// 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)
|
|
}
|
|
|
|
/// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its
|
|
/// own (that's `@Bindable`'s job, and introducing one in `body` would
|
|
/// mean restructuring it away from a single implicit-return expression)
|
|
/// — a manually-built `Binding` over the same reference is simpler here.
|
|
private var selectedSettingsSectionBinding: Binding<SettingsSection?> {
|
|
Binding(
|
|
get: { navigation.selectedSettingsSection },
|
|
set: { navigation.selectedSettingsSection = $0 }
|
|
)
|
|
}
|
|
|
|
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 {
|
|
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: {
|
|
detail
|
|
}
|
|
// Empty rather than a real value: with one, the native title rendered
|
|
// in the same toolbar row as the custom `.navigation` pill below,
|
|
// visibly duplicating it. `.windowToolbarStyle(showsTitle: false)` was
|
|
// tried to suppress just the native text, but it also suppressed the
|
|
// custom `.navigation` item itself — an empty title sidesteps both
|
|
// problems (Window-menu entry is blank as a result).
|
|
.navigationTitle("")
|
|
.toolbar {
|
|
// Per HIG: "sidebar-toggle/back controls appear at the far leading
|
|
// edge, followed by the view title" — this is that title, not
|
|
// centered. It doesn't collide with the back button because they're
|
|
// mutually exclusive: whenever a document's pushed (back button
|
|
// visible), this shows the document hierarchy instead of falling
|
|
// back to the workspace badge.
|
|
ToolbarItem(placement: .navigation) {
|
|
Button {
|
|
goHome()
|
|
} label: {
|
|
Image(systemName: "house")
|
|
}
|
|
.help("Home")
|
|
}
|
|
ToolbarItem(placement: .navigation) {
|
|
leadingToolbarContent
|
|
}
|
|
// Custom instead of `.searchable`: that modifier always renders a
|
|
// full-width field, but this is meant to sit alongside the other
|
|
// per-document toolbar buttons as a plain icon that only expands
|
|
// into a field once clicked. Hidden on the Home landing page
|
|
// itself — `contextualSearchQuery` is only ever read by
|
|
// `CollectionOverviewView`, so on Home it was a dead end: a
|
|
// user could click it, type, and nothing would happen. The
|
|
// sidebar's global search already covers "search everything."
|
|
if !(isShowingHome && documentPath.isEmpty) {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
contextualSearchField
|
|
}
|
|
}
|
|
}
|
|
// Any explicit collection pick — sidebar click, "Search in
|
|
// Collection" — means the user has navigated away from Home.
|
|
.onChange(of: selectedCollection) { _, newValue in
|
|
if newValue != nil {
|
|
isShowingHome = false
|
|
}
|
|
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 }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func goHome() {
|
|
globalSearchQuery = ""
|
|
contextualSearchQuery = ""
|
|
isContextualSearchExpanded = false
|
|
selectedCollection = nil
|
|
isShowingHome = true
|
|
navigation.isShowingSettings = false
|
|
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 {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundStyle(.secondary)
|
|
TextField(
|
|
selectedCollection.map { "Search \($0.name)" } ?? "Search",
|
|
text: $contextualSearchQuery
|
|
)
|
|
.textFieldStyle(.plain)
|
|
.focused($isContextualSearchFocused)
|
|
.frame(width: 180)
|
|
|
|
if !contextualSearchQuery.isEmpty {
|
|
Button {
|
|
contextualSearchQuery = ""
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(.fill.tertiary, in: Capsule())
|
|
.onChange(of: isContextualSearchFocused) { _, focused in
|
|
if !focused && contextualSearchQuery.isEmpty {
|
|
isContextualSearchExpanded = false
|
|
}
|
|
}
|
|
} else {
|
|
Button {
|
|
isContextualSearchExpanded = true
|
|
Task { @MainActor in isContextualSearchFocused = true }
|
|
} label: {
|
|
Image(systemName: "magnifyingglass")
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var leadingToolbarContent: some View {
|
|
Group {
|
|
if !trimmedGlobalQuery.isEmpty {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "magnifyingglass")
|
|
Text("Search")
|
|
}
|
|
} else if !documentPath.isEmpty {
|
|
// Origin (collection, or Home if opened from there) → every
|
|
// ancestor (icon only) → current document (icon + full
|
|
// title) — ancestors stay icon-only so a deep chain doesn't
|
|
// blow out the toolbar width. Checked before `isShowingHome`
|
|
// since opening a document from Home still leaves that flag
|
|
// set — the pushed document should win either way.
|
|
HStack(spacing: 6) {
|
|
if let selectedCollection {
|
|
CollectionRowView(collection: selectedCollection)
|
|
} else {
|
|
Image(systemName: "house.fill")
|
|
}
|
|
|
|
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
|
|
if let emoji = document.emoji {
|
|
Text(emoji)
|
|
} else {
|
|
Image(systemName: "doc.text")
|
|
}
|
|
|
|
if index == documentPath.count - 1 {
|
|
Text(document.title.isEmpty ? "Untitled" : document.title)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
} else if isShowingHome {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "house.fill")
|
|
Text("Home")
|
|
}
|
|
} else if let selectedCollection {
|
|
CollectionRowView(collection: selectedCollection)
|
|
} else {
|
|
HStack(spacing: 8) {
|
|
AvatarBadge(avatarURL: session.teamAvatarURL, placeholderSystemImage: "text.book.closed.fill")
|
|
Text(session.teamName ?? "Outpost")
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.font(.headline)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(.fill.tertiary, in: Capsule())
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var sidebar: some View {
|
|
if let apiClient = session.apiClient {
|
|
CollectionsTreeView(
|
|
apiClient: apiClient,
|
|
selectedCollection: $selectedCollection,
|
|
selectedDocumentID: documentPath.last?.id,
|
|
externalRefreshToken: documentsChangedToken,
|
|
isShowingHome: isShowingHome,
|
|
onSelectHome: goHome,
|
|
onSelectDocument: selectDocumentChain,
|
|
onSearchInCollection: searchInCollection
|
|
)
|
|
} else {
|
|
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
|
}
|
|
}
|
|
|
|
private func searchInCollection(_ collection: OutlineCollection) {
|
|
globalSearchQuery = ""
|
|
selectedCollection = collection
|
|
isContextualSearchExpanded = true
|
|
Task { @MainActor in
|
|
isContextualSearchFocused = true
|
|
}
|
|
}
|
|
|
|
/// Sidebar clicks (which know the real ancestor chain via DocumentNode):
|
|
/// replaces the whole stack with `chain`, so the toolbar shows full
|
|
/// hierarchy immediately rather than just the leaf.
|
|
private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) {
|
|
selectedCollection = collection
|
|
replaceDocumentPath(with: chain)
|
|
}
|
|
|
|
/// Flat-list / search-result clicks: no known ancestors, single-level push.
|
|
private func openDocument(_ document: OutlineDocument) {
|
|
replaceDocumentPath(with: [document])
|
|
}
|
|
|
|
/// Replacing `path` with a same-size array can silently fail to update
|
|
/// NavigationStack on macOS — popping to empty and pushing again as two
|
|
/// separate state updates (so SwiftUI processes them as two render
|
|
/// passes) is the reliable version of the same operation. Appending
|
|
/// (e.g. opening a sub-document from within the reader) doesn't hit this
|
|
/// and can mutate `documentPath` directly.
|
|
private func replaceDocumentPath(with chain: [OutlineDocument]) {
|
|
documentPath = []
|
|
Task { @MainActor in
|
|
documentPath = chain
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var detail: some View {
|
|
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) {
|
|
Group {
|
|
if !trimmedGlobalQuery.isEmpty {
|
|
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
|
|
} else if isShowingHome {
|
|
HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 })
|
|
} else if let selectedCollection {
|
|
CollectionOverviewView(
|
|
apiClient: apiClient,
|
|
collection: selectedCollection,
|
|
searchQuery: $contextualSearchQuery,
|
|
onOpenDocument: openDocument
|
|
)
|
|
} else {
|
|
ContentUnavailableView(
|
|
"No Collection Selected",
|
|
systemImage: "sidebar.left",
|
|
description: Text("Choose a collection to see its documents.")
|
|
)
|
|
}
|
|
}
|
|
.navigationDestination(for: OutlineDocument.self) { document in
|
|
DocumentReaderView(
|
|
apiClient: apiClient,
|
|
document: document,
|
|
onOpenChild: { child in documentPath.append(child) },
|
|
onDeleted: {
|
|
if !documentPath.isEmpty {
|
|
documentPath.removeLast()
|
|
}
|
|
// Delete/Archive/Unpublish/Move all change what
|
|
// should show in the sidebar tree — this only
|
|
// popped the reader before, leaving the sidebar
|
|
// showing the document until some unrelated
|
|
// trigger (the 45s poll, navigating away and
|
|
// back) happened to refresh it.
|
|
documentsChangedToken += 1
|
|
},
|
|
onDocumentCreated: { documentsChangedToken += 1 }
|
|
)
|
|
}
|
|
}
|
|
.id(trimmedGlobalQuery.isEmpty ? (isShowingHome ? "home" : (selectedCollection?.id ?? "none")) : "search")
|
|
} else {
|
|
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
|
}
|
|
}
|
|
}
|
|
#endif
|