feat(editor): Split View (raw Markdown / live preview), remove Sub-documents

New Outpost-local Settings → Editor section (not synced to Outline,
same as Appearance) with a Split View toggle: raw Markdown source on
the left (plain TextEditor, not the rendering engine), the same rich
rendering used everywhere else in the app on the right, read-only,
live-updating off the same text binding.

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

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

Also removed the "Sub-documents" section from the reader per explicit
request — the childrenSection view, and the now-unnecessary
listDocuments(parentDocumentId:) fetch backing it in the view model.
This commit is contained in:
2026-08-19 15:42:35 +01:00
parent 1229678c00
commit bdde8642f7
4 changed files with 137 additions and 65 deletions
@@ -14,6 +14,7 @@ struct SettingsView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false @AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false
@@ -92,6 +93,7 @@ struct SettingsView: View {
private var sectionDetail: some View { private var sectionDetail: some View {
switch section { switch section {
case .appearance: appearanceDetail case .appearance: appearanceDetail
case .editor: editorDetail
case .profile: profileDetail case .profile: profileDetail
case .preferences: preferencesDetail case .preferences: preferencesDetail
case .notifications: notificationsDetail case .notifications: notificationsDetail
@@ -139,6 +141,29 @@ 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)
}
}
// MARK: - Profile // MARK: - Profile
private var profileDetail: some View { private var profileDetail: some View {
@@ -13,6 +13,9 @@ struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore @Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Local-only Outpost setting (Settings Editor), not synced to
/// Outline see `SettingsView.editorDetail`.
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@State private var viewModel: DocumentReaderViewModel @State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
@@ -71,45 +74,27 @@ struct DocumentReaderView: View {
session.networkMonitor.isOnline && !isOfflineModeEnabled session.networkMonitor.isOnline && !isOfflineModeEnabled
} }
/// Split View needs the full window height (each pane scrolls itself),
/// which an unbounded page-level `ScrollView` can't give it a
/// `minHeight` inside one just resolves to exactly that minimum, not
/// "fill available space", since there's no bounded space to fill.
/// Only switches over once there's real content to show; loading/error
/// states still go through the normal scrolling layout.
private var canShowSplitView: Bool {
isSplitViewEnabled
&& viewModel.isEffectivelyEditable
&& viewModel.errorMessage == nil
&& !(viewModel.isLoading && viewModel.text.isEmpty)
}
var body: some View { var body: some View {
ScrollView { Group {
VStack(alignment: .leading, spacing: 12) { if canShowSplitView {
if viewModel.isEffectivelyEditable { splitViewContent
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else { } else {
NativeTextViewWrapper( scrollingReaderContent
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable
)
if !viewModel.children.isEmpty {
childrenSection
} }
} }
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
if viewModel.isLoading && !viewModel.text.isEmpty { if viewModel.isLoading && !viewModel.text.isEmpty {
ProgressView() ProgressView()
@@ -296,29 +281,97 @@ struct DocumentReaderView: View {
} }
} }
private var childrenSection: some View { /// Today's single-pane layout page-level `ScrollView` wrapping title +
VStack(alignment: .leading, spacing: 8) { /// content, used for the normal reading/editing view, and for every
Divider() /// loading/error state regardless of Split View.
.padding(.vertical, 4) private var scrollingReaderContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if viewModel.isEffectivelyEditable {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
Text("Sub-documents") if viewModel.isLoading && viewModel.text.isEmpty {
.font(.caption.weight(.semibold)) ProgressView()
.foregroundStyle(.secondary) .frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable
)
}
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
}
ForEach(viewModel.children) { child in /// Split View's layout title fixed at the top (not part of either
Button { /// scrolling pane), `splitEditorView` filling every remaining pixel of
onOpenChild(child) /// the window below it. No outer `ScrollView` here on purpose: each
} label: { /// pane already scrolls itself, and nesting that inside another
DocumentRowView(document: child) /// unbounded scroll container is exactly what was capping both panes
} /// at a fixed height instead of spanning the window.
.buttonStyle(.plain) private var splitViewContent: some View {
.padding(.vertical, 4) VStack(alignment: .leading, spacing: 12) {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
.padding([.horizontal, .top])
if child.id != viewModel.children.last?.id { splitEditorView
Divider() .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
/// Left is a plain, unrendered raw-text editor (deliberately not
/// `NativeTextViewWrapper` just the literal Markdown source); right
/// is the same rich rendering used everywhere else in the app,
/// read-only, bound to the same `viewModel.text` so it updates live as
/// the left side is typed into.
///
/// Scroll position between the two panes is **not** synchronized the
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
/// private internal view hierarchy to find its scroll view (the package
/// exposes no scroll position/delegate hook at all), which is fragile
/// enough to break silently on a package update. Flagged as a known
/// follow-up, not attempted here.
private var splitEditorView: some View {
HSplitView {
TextEditor(text: $viewModel.text)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(8)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: false
)
.padding(8)
.frame(maxWidth: .infinity, alignment: .topLeading)
} }
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@ViewBuilder @ViewBuilder
@@ -10,7 +10,6 @@ final class DocumentReaderViewModel {
var text: String var text: String
var collectionId: String? var collectionId: String?
var isFullWidth = false var isFullWidth = false
var children: [OutlineDocument] = []
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
@@ -102,13 +101,6 @@ final class DocumentReaderViewModel {
} catch { } catch {
errorMessage = "Couldn't load this document. Check your connection and try again." errorMessage = "Couldn't load this document. Check your connection and try again."
} }
children = (try? await apiClient.listDocuments(
collectionId: nil,
parentDocumentId: documentId,
offset: 0,
limit: 100
)) ?? []
} }
func loadViewers() async { func loadViewers() async {
+5 -3
View File
@@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
/// explicitly built yet. Content lands section by section. /// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable { enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours) // General (ours)
case appearance, offlineSync, advanced, about case appearance, editor, offlineSync, advanced, about
// Account // Account
case profile, preferences, notifications, passkeys, apiAccess case profile, preferences, notifications, passkeys, apiAccess
@@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var category: SettingsCategory { var category: SettingsCategory {
switch self { switch self {
case .appearance, .offlineSync, .advanced, .about: case .appearance, .editor, .offlineSync, .advanced, .about:
return .general return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess: case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account return .account
@@ -57,6 +57,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var title: String { var title: String {
switch self { switch self {
case .appearance: return "Appearance" case .appearance: return "Appearance"
case .editor: return "Editor"
case .offlineSync: return "Offline & Sync" case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced" case .advanced: return "Advanced"
case .about: return "About" case .about: return "About"
@@ -85,6 +86,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var icon: String { var icon: String {
switch self { switch self {
case .appearance: return "paintbrush" case .appearance: return "paintbrush"
case .editor: return "square.split.2x1"
case .offlineSync: return "arrow.triangle.2.circlepath" case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver" case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle" case .about: return "info.circle"
@@ -115,7 +117,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
/// specified and built. /// specified and built.
var isImplemented: Bool { var isImplemented: Bool {
switch self { switch self {
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess: case .appearance, .editor, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true return true
default: default:
return false return false