Files
oai-swift/oAI/Views/Screens/HistoryView.swift
T
runeandClaude Sonnet 5 cf3f4ebfe4 Relicense oAI from AGPL-3.0-or-later to PolyForm Noncommercial 1.0.0
Switches the project from AGPL to a source-available license that
restricts commercial use — selling oAI or any part of it, standalone
or bundled into another product/service, now requires a separate
commercial license from the copyright holder. Noncommercial use,
study, modification, and sharing remain fully permitted.

Updates: LICENSE (canonical PolyForm Noncommercial 1.0.0 text +
commercial licensing contact note), SPDX headers and file-header
boilerplate across all Swift source files, the in-app About dialog's
license link (+ its localization catalog entry), README.md and
DEVELOPMENT.md license sections.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:16:09 +02:00

213 lines
7.2 KiB
Swift

//
// HistoryView.swift
// oAI
//
// Command history viewer with search
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://mac.oai.pm>.
import os
import SwiftUI
struct HistoryView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var historyEntries: [HistoryEntry] = []
@State private var selectedIndex: Int = 0
@FocusState private var isListFocused: Bool
var onSelect: ((String) -> Void)?
private var filteredHistory: [HistoryEntry] {
if searchText.isEmpty {
return historyEntries
}
return historyEntries.filter {
$0.input.lowercased().contains(searchText.lowercased()) ||
$0.formattedDate.contains(searchText)
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Command History")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search by text or date (dd.mm.yyyy)...", text: $searchText)
.textFieldStyle(.plain)
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
}
.padding(10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
// Content
if filteredHistory.isEmpty {
Spacer()
VStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "list.bullet" : "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text(searchText.isEmpty ? "No Command History" : "No Matches")
.font(.headline)
.foregroundStyle(.secondary)
Text(searchText.isEmpty ? "Your command history will appear here" : "Try a different search term or date")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
} else {
ScrollViewReader { proxy in
List {
ForEach(Array(filteredHistory.enumerated()), id: \.element.id) { index, entry in
HistoryRow(
entry: entry,
isSelected: index == selectedIndex
)
.contentShape(Rectangle())
.onTapGesture {
selectedIndex = index
onSelect?(entry.input)
dismiss()
}
.id(entry.id)
}
}
.listStyle(.plain)
.focused($isListFocused)
.onChange(of: selectedIndex) {
withAnimation {
proxy.scrollTo(filteredHistory[selectedIndex].id, anchor: .center)
}
}
.onChange(of: searchText) {
selectedIndex = 0
}
#if os(macOS)
.onKeyPress(.upArrow) {
if selectedIndex > 0 {
selectedIndex -= 1
}
return .handled
}
.onKeyPress(.downArrow) {
if selectedIndex < filteredHistory.count - 1 {
selectedIndex += 1
}
return .handled
}
.onKeyPress(.return) {
if !filteredHistory.isEmpty && selectedIndex < filteredHistory.count {
onSelect?(filteredHistory[selectedIndex].input)
dismiss()
}
return .handled
}
#endif
}
}
}
.frame(minWidth: 600, minHeight: 400)
.background(Color.oaiBackground)
.task {
loadHistory()
isListFocused = true
}
}
private func loadHistory() {
do {
let records = try DatabaseService.shared.loadCommandHistory()
historyEntries = records.map { HistoryEntry(input: $0.input, timestamp: $0.timestamp) }
} catch {
Log.db.error("Failed to load command history: \(error.localizedDescription)")
}
}
}
struct HistoryRow: View {
let entry: HistoryEntry
let isSelected: Bool
private let settings = SettingsService.shared
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Input text
Text(entry.input)
.font(.system(size: settings.dialogTextSize))
.foregroundStyle(.primary)
.lineLimit(3)
// Timestamp
HStack(spacing: 4) {
Image(systemName: "clock")
.font(.caption2)
.foregroundStyle(.secondary)
Text(entry.formattedDate)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
.listRowBackground(isSelected ? Color.oaiAccent.opacity(0.2) : Color.clear)
}
}
#Preview {
HistoryView { input in
print("Selected: \(input)")
}
}
#Preview("History Row") {
HistoryRow(
entry: HistoryEntry(
input: "Tell me about Swift concurrency",
timestamp: Date()
),
isSelected: true
)
}