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>
88 lines
4.1 KiB
Swift
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 }
|
|
}
|
|
}
|