Skip to content
Merged

Dev #26

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions App/Composition/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,25 @@ final class AppEnvironment: ObservableObject {
/// native OAuth flow. Exposed as the protocol so test doubles substitute in.
let github: GitHubServicing

/// Synced app settings + the per-machine device registry
/// (work-consolidation.md G17) — the platform's own mechanism for companion
/// apps, and the sanctioned home for this app's preferences and the
/// Document Sync Agent's per-machine configuration. Optional because it is
/// only usable once an `appKey` is registered with the backend owner; the
/// Settings panes render an explicit unavailable state while it is nil.
let appSettings: AppSettingsServicing?

/// The server-driven notification-preferences catalogue
/// (work-consolidation.md G18) that Settings ▸ Notifications renders itself from.
let notificationPreferences: NotificationPreferencesServicing?

/// Active sessions + revocation (work-consolidation.md G19) for
/// Settings ▸ Security.
let sessions: SessionsServicing?

/// Tag trending + autocomplete (work-consolidation.md G20).
let tags: TagsServicing?

/// The Direct Messages surface the Messages feature binds against
/// (work-consolidation.md G1). Exposed as the protocol so test doubles
/// substitute in. Wraps the `/api/messages/*` DM endpoints (folders,
Expand Down Expand Up @@ -256,7 +275,11 @@ final class AppEnvironment: ObservableObject {
directMessages: DirectMessagesServicing,
directMessagesEventBus: DirectMessagesEventBus,
sharingAPI: APIClientProtocol,
shareBaseURL: URL
shareBaseURL: URL,
appSettings: AppSettingsServicing? = nil,
notificationPreferences: NotificationPreferencesServicing? = nil,
sessions: SessionsServicing? = nil,
tags: TagsServicing? = nil
) {
self.messages = messages
self.lists = lists
Expand Down Expand Up @@ -286,8 +309,23 @@ final class AppEnvironment: ObservableObject {
self.directMessagesEventBus = directMessagesEventBus
self.sharingAPI = sharingAPI
self.shareBaseURL = shareBaseURL
self.appSettings = appSettings
self.notificationPreferences = notificationPreferences
self.sessions = sessions
self.tags = tags
}

/// The app-settings key this client registers under (work-consolidation.md
/// G17).
///
/// ⚠️ **Nil until an `appKey` is registered with the backend owner** — that
/// registration is a stated prerequisite of G17, and calling the routes with
/// an unregistered key 404s. While nil, `AppEnvironment.appSettings` is nil
/// and the Settings panes render an explicit "not configured" state instead
/// of failing opaquely. Set this to the agreed key to switch the feature on;
/// it is deliberately the single edit required.
static let appSettingsKey: String? = nil

/// Builds the production service graph:
///
/// `KeychainTokenStore` → `DefaultAuthTransport` (Bearer-only for
Expand Down Expand Up @@ -455,6 +493,16 @@ final class AppEnvironment: ObservableObject {
// dock-badge coordinator all see the same stream.
let directMessages = DirectMessagesService(api: api)
let directMessagesEventBus = DirectMessagesEventBus()
// Settings cluster (work-consolidation.md G17-G20). All reuse the shared
// kit-layer `APIClient`.
// • G17 app settings + device registry — gated on `appSettingsKey`
// being registered with the backend owner; nil until then, and the
// panes say so rather than failing opaquely.
// • G18 notification preferences, G19 sessions, G20 tags.
let appSettings = Self.appSettingsKey.map { AppSettingsService(api: api, appKey: $0) }
let notificationPreferences = NotificationPreferencesService(api: api)
let sessions = SessionsService(api: api)
let tags = TagsService(api: api)
return AppEnvironment(
messages: messages,
lists: lists,
Expand Down Expand Up @@ -489,7 +537,11 @@ final class AppEnvironment: ObservableObject {
// the canonical web-URL builder for links the server returns
// without a pre-built `url`.
sharingAPI: api,
shareBaseURL: InterlinedKit.defaultBaseURL
shareBaseURL: InterlinedKit.defaultBaseURL,
appSettings: appSettings,
notificationPreferences: notificationPreferences,
sessions: sessions,
tags: tags
)
}

Expand Down
37 changes: 37 additions & 0 deletions App/Composition/DeviceIdentity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// DeviceIdentity
//
// This machine's stable identifier for the app-settings device registry
// (work-consolidation.md G17).
//
// Deliberately still `UserDefaults`-backed even though G17 exists to replace
// local settings state: the device id is what *addresses* a row in the remote
// registry, so it cannot itself live there without a chicken-and-egg problem.
// It is an opaque UUID minted once per machine — no hardware identifier, so it
// carries nothing personally identifying and resets cleanly if the user wipes
// preferences.

import Foundation

enum DeviceIdentity {

private static let defaultsKey = "com.interlinedlist.deviceId"

/// The stable id for this machine, minting and persisting one on first use.
static func current(defaults: UserDefaults = .standard) -> String {
if let existing = defaults.string(forKey: defaultsKey), !existing.isEmpty {
return existing
}
let minted = UUID().uuidString
defaults.set(minted, forKey: defaultsKey)
return minted
}

/// A human-friendly default name for this machine, used when registering.
/// `ProcessInfo.hostName` avoids AppKit entirely (Decision: SwiftUI-only App
/// target) and matches what the user sees in Sharing preferences.
static var suggestedName: String {
let host = ProcessInfo.processInfo.hostName
// `hostName` often comes back as "studio-mac.local"; trim the suffix.
return host.hasSuffix(".local") ? String(host.dropLast(6)) : host
}
}
46 changes: 45 additions & 1 deletion App/Features/Compose/ComposerWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ struct ComposerWindowView: View {

/// Controls the `.fileImporter` sheet for picking media.
@State private var isImporterPresented = false
/// Tag completion (work-consolidation.md G20). Owned by the view, not by
/// `ComposerViewModel`, so the publish path is untouched.
@State private var tagCompletion: TagCompletionViewModel?

var body: some View {
Group {
Expand Down Expand Up @@ -435,11 +438,52 @@ struct ComposerWindowView: View {
.foregroundStyle(.secondary)
TextField("Comma- or space-separated", text: Binding(
get: { viewModel.tagsInput },
set: { viewModel.tagsInput = $0 }
set: { newValue in
viewModel.tagsInput = newValue
// Completion tracks the token currently being typed
// (work-consolidation.md G20).
tagCompletion?.input(changed: newValue)
}
))
.textFieldStyle(.roundedBorder)
.accessibilityLabel("Tags")

if let tagCompletion, tagCompletion.isShowing {
tagSuggestions(tagCompletion, viewModel: viewModel)
}
}
.task {
if tagCompletion == nil {
tagCompletion = TagCompletionViewModel(service: environment?.tags)
}
}
}

/// The completion row. A plain wrapping row rather than a `.popover` so it
/// never steals focus from the field the user is still typing in.
@ViewBuilder
private func tagSuggestions(
_ completion: TagCompletionViewModel,
viewModel: ComposerViewModel
) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(completion.suggestions, id: \.self) { suggestion in
Button {
viewModel.tagsInput = completion.apply(suggestion, to: viewModel.tagsInput)
} label: {
Text("#\(suggestion)")
.font(.ilMono(10))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.tint.opacity(0.12), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel("Add tag \(suggestion)")
}
}
}
.frame(maxHeight: 28)
}

@ViewBuilder
Expand Down
110 changes: 110 additions & 0 deletions App/Features/Compose/TagCompletionViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// TagCompletionViewModel
//
// Drives the composer's tag-completion popover (work-consolidation.md G20).
//
// Deliberately owned by the *view*, not by `ComposerViewModel`: completion is a
// presentation affordance over the existing free-form `tagsInput` string, so
// keeping it separate leaves the composer's publish path — and its signature —
// untouched.
//
// The composer's tag field is comma/space separated, so completion applies to
// the **last token being typed**; earlier tokens are already committed.
//
// Per Decision 0003 this view model consumes only `InterlinedDomain`.

import Foundation
import Observation
import InterlinedDomain

@MainActor
@Observable
final class TagCompletionViewModel {

private let service: TagsServicing?
/// How long to wait after the last keystroke before asking the server.
private let debounce: Duration
/// The in-flight lookup, cancelled when a newer keystroke supersedes it.
private var task: Task<Void, Never>?

private(set) var suggestions: [String] = []

/// True when there is something to show. The view binds its popover to this.
var isShowing: Bool { !suggestions.isEmpty }

init(service: TagsServicing?, debounce: Duration = .milliseconds(200)) {
self.service = service
self.debounce = debounce
}

/// Call on every change to the raw tag-input string.
func input(changed raw: String) {
task?.cancel()
let prefix = Self.activeToken(in: raw)
// One character is too weak a prefix to be useful and matches most of
// the corpus; wait for a second before asking.
guard let service, prefix.count >= 2 else {
suggestions = []
return
}
task = Task { [debounce] in
try? await Task.sleep(for: debounce)
guard !Task.isCancelled else { return }
do {
let found = try await service.suggestions(prefix: prefix, limit: 8)
guard !Task.isCancelled else { return }
// Drop anything already committed earlier in the field.
let committed = Set(Self.committedTokens(in: raw).map { $0.lowercased() })
suggestions = found.filter { !committed.contains($0.lowercased()) }
} catch {
// Completion is an optional nicety — a failed lookup silently
// shows nothing rather than interrupting composition with an
// error the user cannot act on.
suggestions = []
}
}
}

/// Replaces the token being typed with `suggestion`, returning the new field
/// value. Leaves a trailing space so the next tag can be typed immediately.
func apply(_ suggestion: String, to raw: String) -> String {
var tokens = Self.committedTokens(in: raw)
tokens.append(suggestion)
suggestions = []
task?.cancel()
return tokens.joined(separator: " ") + " "
}

func dismiss() {
task?.cancel()
suggestions = []
}

// MARK: - Token parsing
//
// Mirrors `ComposerViewModel.normalise(tags:)`: split on commas and
// whitespace, strip a leading `#`.

private static let separators = CharacterSet(charactersIn: ", \t\n")

/// The token currently being typed — the trailing fragment. Empty when the
/// field ends in a separator (nothing is being typed right now).
static func activeToken(in raw: String) -> String {
guard let last = raw.unicodeScalars.last, !separators.contains(last) else { return "" }
let fragment = raw.components(separatedBy: separators as CharacterSet).last ?? ""
return fragment.hasPrefix("#") ? String(fragment.dropFirst()) : fragment
}

/// Every token except the one being typed.
static func committedTokens(in raw: String) -> [String] {
var parts = raw
.components(separatedBy: separators as CharacterSet)
.map { $0.hasPrefix("#") ? String($0.dropFirst()) : $0 }
.filter { !$0.isEmpty }
// If the field does not end in a separator, the final part is still
// being typed and is not yet committed.
if let last = raw.unicodeScalars.last, !separators.contains(last), !parts.isEmpty {
parts.removeLast()
}
return parts
}
}
19 changes: 8 additions & 11 deletions App/Features/Lists/GitHubIssuesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -334,19 +334,16 @@ private struct GitHubIssueDetailView: View {
.padding()
}

// NOTE: there is deliberately no Close / Reopen control here. The live
// `PATCH /api/github/issues/{owner}/{repo}/{number}` route only sets labels
// and assignees — a `state` change comes back
// `400 "labels or assignees required"` (verified 2026-09-06,
// work-consolidation.md §1c · V7). The button used to sit in this bar and
// could never have worked against production, so it was removed rather than
// left to fail; "Open on GitHub" in the header is the working path until the
// backend grows a state route.
private var editingBar: some View {
HStack(spacing: 8) {
Button {
Task { await viewModel.toggleState(issue) }
} label: {
if issue.state == .closed {
Label("Reopen", systemImage: "arrow.counterclockwise.circle")
} else {
Label("Close", systemImage: "checkmark.circle")
}
}
.disabled(viewModel.isUpdating)

Menu {
if viewModel.labelCatalog.isEmpty {
Text(viewModel.isLoadingCatalog ? "Loading…" : "No labels")
Expand Down
15 changes: 10 additions & 5 deletions App/Features/Lists/GitHubIssuesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,11 @@ final class GitHubIssuesViewModel {
}
}

/// Closes an open issue or reopens a closed one.
func toggleState(_ issue: GitHubIssue) async {
let newState: GitHubIssueState = issue.state == .closed ? .open : .closed
await applyUpdate(to: issue.number, GitHubIssueUpdate(state: newState))
}
// No `toggleState` here: closing / reopening an issue has no live route.
// `PATCH /api/github/issues/{owner}/{repo}/{number}` rejects a `state`-only
// body with 400 "labels or assignees required" (work-consolidation.md
// §1c · V7), and `GitHubService.updateIssue` now refuses such an update up
// front with `GitHubServiceError.unsupportedIssueEdit`.

/// Replaces the label set on `issue`.
func setLabels(_ labels: [String], on issue: GitHubIssue) async {
Expand Down Expand Up @@ -216,6 +216,11 @@ final class GitHubIssuesViewModel {
switch error {
case .notLinked:
linkState = .notLinked
case .unsupportedIssueEdit:
// Not a linking problem — the live API simply has no route for this
// edit, so surface the message instead of showing a "Link GitHub"
// CTA the user has already satisfied.
self.error = error
}
}

Expand Down
Loading