feat(editor): Remember previous location + Command Palette (⌘K)

Remember previous location (Preferences): persists collection +
document chain (or Home) to UserDefaults on every navigation change,
gated on the preference. Restored once per launch by resolving the
stored IDs back through the API, stopping at the first failure
(deleted doc, offline, etc.) rather than aborting the whole restore —
a partial chain beats falling all the way back to Home. Cleared on
sign-out so switching accounts can't restore a stale location.

Command Palette (new Settings → Editor section, not synced to
Outline): ⌘K opens a floating overlay, arrow-key/click navigation,
Enter or click to select. Always searches locally, never a
per-keystroke network request:
- Lightweight (default): live listCollections + listViewedDocuments
  fetch once on open.
- Full Workspace (opt-in, requires Full Local Sync on): reads
  CachingOutlineAPIClient's local cache directly — zero network calls,
  includes nested sub-documents now that Full Local Sync actually
  caches them (see the paired OutlineKit commit).

Fixed through live testing, in order found:
- Focus: the search field wasn't reliably first responder the instant
  ⌘K opened it (also the likely source of several AppKit
  CA-transaction console warnings) — added a short delay before
  focusing.
- Full Workspace "no results": was sequential one-collection-at-a-time
  fetching before the cache-read redesign: withTaskGroup made it
  concurrent, and reading from the cache instead of the network made
  it moot.
- Arrow keys not moving selection: .onKeyPress was on the outer card,
  but the focused TextField swallowed the events before they could
  bubble up. Moved the handlers directly onto the TextField.
- Search ranking: exact-phrase-only matching meant a title like "Test
  Plan Document" never matched a "test document" query at all
  (filtered out, not just ranked low), making it look like collections
  always won. Added a fallback tier: every word of a multi-word query
  present anywhere in the title still matches, ranked below
  exact/prefix/substring hits.
- foregroundStyle(.secondary vs .orange) ternary: HierarchicalShapeStyle
  vs Color type mismatch, fixed with AnyShapeStyle on both branches.
This commit is contained in:
2026-08-19 17:14:49 +01:00
parent bef194d493
commit 195dc2cc59
6 changed files with 395 additions and 0 deletions
@@ -15,6 +15,8 @@ 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("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(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
@@ -161,7 +163,40 @@ struct SettingsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: 480, alignment: .leading) .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 // MARK: - Profile
@@ -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(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation @Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @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 /// The landing state no collection selected yet is what Home actually
/// means, so this starts `true` rather than auto-selecting the first /// means, so this starts `true` rather than auto-selecting the first
/// collection the way this used to work. /// 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 /// Home's "New Document" buttons) every expanded sidebar row reloads
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`. /// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
@State private var documentsChangedToken = 0 @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 { private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -147,6 +153,38 @@ struct ContentView_macOS: View {
if newValue != nil { if newValue != nil {
isShowingHome = false 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: []) 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 @ViewBuilder
private var contextualSearchField: some View { private var contextualSearchField: some View {
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty { if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
+12
View File
@@ -19,6 +19,7 @@ struct OutpostApp: App {
#if os(macOS) #if os(macOS)
@State private var isShowingLogoutConfirmation = false @State private var isShowingLogoutConfirmation = false
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
#endif #endif
var body: some Scene { var body: some Scene {
@@ -60,6 +61,17 @@ struct OutpostApp: App {
} }
.disabled(!session.isSignedIn) .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 #endif
+2
View File
@@ -136,4 +136,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
final class AppNavigation { final class AppNavigation {
var isShowingSettings = false var isShowingSettings = false
var selectedSettingsSection: SettingsSection? = .appearance var selectedSettingsSection: SettingsSection? = .appearance
/// K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
var isShowingCommandPalette = false
} }
+4
View File
@@ -126,6 +126,10 @@ final class SessionStore {
apiClient = nil apiClient = nil
cachingClient = nil cachingClient = nil
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey) 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 /// Re-fetches user/workspace name/logo on relaunch, when the token survived but this