SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
6.8 KB · 158 lines swift
Raw Blame History
1//2//  HistoryView.swift3//  Zyquo Atlas4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Full-text history (⌘Y): grouped by day, searchable, with delete controls and9//  "ask AI about my history" — the model receives only titles/hosts (never page10//  content), on explicit user action, via the shared provider layer.11//1213import SwiftUI1415struct HistoryView: View {16    let onOpen: (String) -> Void17    @EnvironmentObject private var env: AppEnvironment18    @EnvironmentObject private var themeEngine: ThemeEngine19    @Environment(\.dismiss) private var dismiss20    private var theme: AtlasTheme { themeEngine.theme }2122    @State private var query = ""23    @State private var askText = ""24    @StateObject private var ai = AIService()2526    var body: some View {27        VStack(spacing: 0) {28            header29            Divider().overlay(theme.border)30            askBar31            Divider().overlay(theme.border)32            list33        }34        .frame(width: 660, height: 620)35        .background(theme.backgroundColor)36    }3738    private var header: some View {39        HStack {40            Label("History", systemImage: "clock").font(ZyquoFont.title).foregroundStyle(theme.textPrimary)41            Spacer()42            searchField43            Menu {44                Button("Clear last hour") { env.history.deleteRange(since: Date().addingTimeInterval(-3600)) }45                Button("Clear today") { env.history.deleteRange(since: Calendar.current.startOfDay(for: Date())) }46                Button("Clear all", role: .destructive) { env.history.clearAll() }47            } label: { Image(systemName: "trash") }48                .menuStyle(.borderlessButton).frame(width: 44).foregroundStyle(theme.textSecondary)49            Button("Done") { dismiss() }.foregroundStyle(theme.accent)50        }51        .padding(ZyquoSpacing.md)52        .background(theme.surface)53    }5455    private var searchField: some View {56        HStack(spacing: ZyquoSpacing.xs) {57            Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundStyle(theme.textTertiary)58            TextField("Search history", text: $query).textFieldStyle(.plain).font(ZyquoFont.control)59                .foregroundStyle(theme.textPrimary).frame(width: 180)60        }61        .padding(.horizontal, ZyquoSpacing.sm).frame(height: 28)62        .background(RoundedRectangle(cornerRadius: ZyquoRadius.small).fill(theme.surfaceSecondary))63    }6465    private var askBar: some View {66        VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {67            HStack(spacing: ZyquoSpacing.xs) {68                Image(systemName: "sparkles").foregroundStyle(theme.accentIndigo)69                TextField("Ask AI about your history (e.g. “that article about maps I read last week”)", text: $askText)70                    .textFieldStyle(.plain).font(ZyquoFont.body())71                    .foregroundStyle(theme.textPrimary)72                    .onSubmit(runAsk)73                if ai.isStreaming { Button("Stop") { ai.cancel() }.font(ZyquoFont.caption).foregroundStyle(theme.danger) }74                else { Button("Ask", action: runAsk).font(ZyquoFont.caption).foregroundStyle(theme.accent) }75            }76            if !ai.output.isEmpty || ai.errorText != nil {77                ScrollView {78                    Text(ai.errorText ?? ai.output)79                        .font(ZyquoFont.body()).foregroundStyle(ai.errorText != nil ? theme.danger : theme.textPrimary)80                        .frame(maxWidth: .infinity, alignment: .leading).textSelection(.enabled)81                }82                .frame(maxHeight: 120)83            }84        }85        .padding(ZyquoSpacing.md)86    }8788    private var list: some View {89        ScrollView {90            LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {91                ForEach(env.history.grouped(query), id: \.day) { group in92                    Section {93                        ForEach(group.items) { entry in94                            row(entry)95                            Divider().overlay(theme.border)96                        }97                    } header: {98                        Text(dayLabel(group.day)).font(ZyquoFont.caption).foregroundStyle(theme.textTertiary)99                            .frame(maxWidth: .infinity, alignment: .leading)100                            .padding(.horizontal, ZyquoSpacing.md).padding(.vertical, ZyquoSpacing.xs)101                            .background(theme.backgroundColor)102                    }103                }104                if env.history.entries.isEmpty {105                    Text("No history yet").font(ZyquoFont.caption).foregroundStyle(theme.textTertiary)106                        .frame(maxWidth: .infinity).padding(ZyquoSpacing.xxl)107                }108            }109        }110    }111112    private func row(_ entry: HistoryEntry) -> some View {113        HStack(spacing: ZyquoSpacing.sm) {114            Image(systemName: "globe").font(.system(size: 11)).foregroundStyle(theme.textTertiary)115            VStack(alignment: .leading, spacing: 1) {116                Text(entry.title).font(ZyquoFont.body(size: 12.5)).lineLimit(1).foregroundStyle(theme.textPrimary)117                Text(entry.host).font(ZyquoFont.caption).lineLimit(1).foregroundStyle(theme.textTertiary)118            }119            Spacer()120            Text(HistoryService.relative(entry.lastVisit)).font(ZyquoFont.caption).foregroundStyle(theme.textTertiary)121            Button { env.history.delete(entry.id) } label: { Image(systemName: "xmark") }122                .buttonStyle(.plain).foregroundStyle(theme.textTertiary)123        }124        .padding(.horizontal, ZyquoSpacing.md).padding(.vertical, ZyquoSpacing.xs)125        .contentShape(Rectangle())126        .onTapGesture { onOpen(entry.url); dismiss() }127    }128129    private func runAsk() {130        let q = askText.trimmingCharacters(in: .whitespacesAndNewlines)131        guard !q.isEmpty, let model = env.model(for: .standard) else { return }132        guard let key = try? env.vault.apiKey(for: model.provider) else {133            askText = ""; return134        }135        let context = env.history.aiContext()136        let prompt = """137        Here is a list of the user's recent browsing history (title — site — when). \138        Answer the user's question by pointing to the most relevant items. If none \139        match, say so. Do not invent entries.140141        QUESTION: \(q)142143        HISTORY:144        \(context)145        """146        ai.runRawPrompt(system: "You help the user find pages in their browsing history.",147                        userText: prompt, model: model, apiKey: key)148    }149150    private func dayLabel(_ day: Date) -> String {151        let cal = Calendar.current152        if cal.isDateInToday(day) { return "Today" }153        if cal.isDateInYesterday(day) { return "Yesterday" }154        let f = DateFormatter(); f.dateStyle = .medium155        return f.string(from: day)156    }157}158