From 195dc2cc593f29a9615d0ec104292196748b889e Mon Sep 17 00:00:00 2001 From: psmattas Date: Wed, 19 Aug 2026 17:14:49 +0100 Subject: [PATCH] =?UTF-8?q?feat(editor):=20Remember=20previous=20location?= =?UTF-8?q?=20+=20Command=20Palette=20(=E2=8C=98K)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Outpost/Features/Account/SettingsView.swift | 35 +++ .../Collections/CommandPaletteView.swift | 247 ++++++++++++++++++ .../Collections/ContentView_macOS.swift | 95 +++++++ Outpost/OutpostApp.swift | 12 + Outpost/Root/AppNavigation.swift | 2 + Outpost/Root/SessionStore.swift | 4 + 6 files changed, 395 insertions(+) create mode 100644 Outpost/Features/Collections/CommandPaletteView.swift diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 5c453c0..4fb94ff 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -15,6 +15,8 @@ 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 @@ -161,9 +163,42 @@ struct SettingsView: View { .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 { diff --git a/Outpost/Features/Collections/CommandPaletteView.swift b/Outpost/Features/Collections/CommandPaletteView.swift new file mode 100644 index 0000000..c34c3e1 --- /dev/null +++ b/Outpost/Features/Collections/CommandPaletteView.swift @@ -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 diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index ad42a31..1a9ce25 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -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 { diff --git a/Outpost/OutpostApp.swift b/Outpost/OutpostApp.swift index e461e20..73e86fc 100644 --- a/Outpost/OutpostApp.swift +++ b/Outpost/OutpostApp.swift @@ -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 diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift index 6335abf..94f0090 100644 --- a/Outpost/Root/AppNavigation.swift +++ b/Outpost/Root/AppNavigation.swift @@ -136,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 } diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index e7a3a1e..a75a04b 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -126,6 +126,10 @@ final class SessionStore { 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