tv-anarchy/Sources/TVAnarchy/LogView.swift
Natalie 92b38b1bae refactor(tv-anarchy): rename PlumTV→TVAnarchy and land session work
Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename
the auto-commit service couldn't stage — it git-add'd the old, now-gone
paths and aborted every cycle), and commits the accumulated work:

- Library: black-built index fast path (LibraryIndex + scanFromIndex) with
  NFS-walk fallback; incremental --add on download-complete; mtime staleness
  gate; loose-file series-collapse fix; determinate scan/index progress.
- Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year).
- Player: sleep timer (timed + end-of-episode); visibility-gated polling.
- Home: Continue Watching cover art + live refresh; Recently Added; adult gate.
- Logs: multi-line selection + copy; truncated giant tx-list errors.
- Hover previews (opt-in) via black ffmpeg + scp.

Also gitignores foreign project trees (governor/mcp/fleet/recommender) that
sit in this directory but belong to their own repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:04:22 -07:00

88 lines
4.1 KiB
Swift

import SwiftUI
import TVAnarchyCore
/// Logs tab: live tail of /tmp/tvanarchy.log with text + level filtering, copy,
/// and clear. Auto-scrolls to the newest line. Rows are multi-selectable
/// (click, -click a range, -click to add) so several lines copy at once.
struct LogView: View {
@Bindable var log: LogController
/// Selected entry ids (native List multi-selection).
@State private var selection = Set<Int>()
var body: some View {
ScrollViewReader { proxy in
List(selection: $selection) {
ForEach(log.filtered) { e in row(e).id(e.id) }
if log.filtered.isEmpty {
Text(log.entries.isEmpty ? "No log yet." : "No lines match the filter.")
.foregroundStyle(.secondary)
}
}
.font(.caption.monospaced())
.contextMenu(forSelectionType: Int.self) { ids in
Button("Copy \(ids.count) line\(ids.count == 1 ? "" : "s")") { copyLines(ids) }
Button("Select all") { selection = Set(log.filtered.map(\.id)) }
} primaryAction: { ids in copyLines(ids) }
.onChange(of: log.entries.count) {
// Only autoscroll when the user isn't holding a selection to copy.
guard selection.isEmpty, let last = log.filtered.last else { return }
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
.navigationTitle("Logs")
.searchable(text: $log.query, placement: .toolbar, prompt: "Filter logs")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
ForEach(Log.Level.allCases, id: \.self) { lvl in levelChip(lvl) }
Button { copyLines(selection) } label: {
Label(copyTitle, systemImage: "doc.on.doc")
}
.help(selection.isEmpty ? "Copy all filtered lines" : "Copy selected lines")
.keyboardShortcut("c", modifiers: .command)
if !selection.isEmpty {
Button { selection = [] } label: { Image(systemName: "xmark.circle") }
.help("Clear selection")
}
Button { log.clear(); selection = [] } label: { Image(systemName: "trash") }.help("Clear log")
}
}
.task { log.start() }
}
private var copyTitle: String { selection.isEmpty ? "Copy all" : "Copy \(selection.count)" }
/// Copy the given line ids (or every filtered line when the set is empty), in
/// display order, as `HH:mm:ss [LEVEL] text`.
private func copyLines(_ ids: Set<Int>) {
let rows = ids.isEmpty ? log.filtered : log.filtered.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return }
copyToClipboard(rows.map { "\($0.time) [\($0.level.rawValue)] \($0.text)" }.joined(separator: "\n"))
}
private func row(_ e: LogController.Entry) -> some View {
HStack(alignment: .top, spacing: 8) {
Text(e.time).foregroundStyle(.tertiary).frame(width: 80, alignment: .leading)
Text(e.level.rawValue).bold().foregroundStyle(color(e.level)).frame(width: 44, alignment: .leading)
Text(e.text).textSelection(.enabled)
.foregroundStyle(e.level == .error ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
Spacer(minLength: 0)
}
.listRowSeparator(.hidden)
}
private func levelChip(_ lvl: Log.Level) -> some View {
let on = log.levels.contains(lvl)
return Button { log.toggle(lvl) } label: {
Text(lvl.rawValue).font(.caption2.bold())
.padding(.horizontal, 7).padding(.vertical, 3)
.background(on ? AnyShapeStyle(color(lvl).opacity(0.25)) : AnyShapeStyle(.quaternary), in: Capsule())
.foregroundStyle(on ? color(lvl) : .secondary)
}
.buttonStyle(.plain)
.help(on ? "Hide \(lvl.rawValue)" : "Show \(lvl.rawValue)")
}
private func color(_ lvl: Log.Level) -> Color {
switch lvl { case .info: .secondary; case .warn: .orange; case .error: .red }
}
}