215 lines
7.3 KiB
Swift
215 lines
7.3 KiB
Swift
//
|
|
// HistoryView.swift
|
|
// oAI
|
|
//
|
|
// Command history viewer with search
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
|
// Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public
|
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
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
|
|
)
|
|
}
|