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