mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(mac-sessions): project filter + badges (v2.5 parity with iOS)
The Mac global Sessions feature rendered all sessions with no project context. ScarfGo's new Sessions tab added a project filter Menu and badge chips on each row in v2.5 — bring the same to Mac so v2.5 lands as a user-visible upgrade on both platforms, not just iOS. Changes: - `SessionsViewModel`: load `~/.hermes/scarf/session_project_map.json` + the project registry off the main actor (single batched read, matches the iOS Dashboard pattern). Exposes `sessionProjectNames`, `allProjects`, `projectFilter`, `filteredSessions`, and `projectName(for:)`. - `SessionsView`: filter bar above the list (shown only when at least one project is registered) with a Menu listing "All projects", "Unattributed", and each registered project. An xmark button clears the filter. The right side shows "X of Y shown" so the filter's effect is obvious. - `SessionRow` (shared with Dashboard): gains an optional `projectName: String?` parameter that renders a tinted folder chip alongside the relative date when set. Both services already lived in ScarfCore (moved there in v2.5's iOS work), so this is pure UI consumption — no new shared logic. Verified: Mac build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -198,6 +198,11 @@ struct StatCard: View {
|
||||
struct SessionRow: View {
|
||||
let session: HermesSession
|
||||
var preview: String?
|
||||
/// Optional project display name to render as a chip below the title.
|
||||
/// Nil for unattributed / quick-chat sessions. Surfaced on the
|
||||
/// Sessions list (v2.5) and reusable from any other site that wants
|
||||
/// the same visual.
|
||||
var projectName: String? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
@@ -207,10 +212,21 @@ struct SessionRow: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(preview ?? session.displayTitle)
|
||||
.lineLimit(1)
|
||||
if let date = session.startedAt {
|
||||
Text(date, style: .relative)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
if let date = session.startedAt {
|
||||
Text(date, style: .relative)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let projectName, !projectName.isEmpty {
|
||||
Label(projectName, systemImage: "folder.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tint)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, 5)
|
||||
.background(.tint.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
||||
@@ -37,6 +37,40 @@ final class SessionsViewModel {
|
||||
var showDeleteConfirmation = false
|
||||
var deleteSessionId: String?
|
||||
|
||||
// MARK: - Project attribution (v2.5)
|
||||
//
|
||||
// Session-to-project lookup populated from `~/.hermes/scarf/session_project_map.json`
|
||||
// + the project registry. Drives the "Project" filter Menu above the
|
||||
// list and the badge chip in each session row. Mirrors the same
|
||||
// services iOS uses on the Dashboard's Sessions tab — both platforms
|
||||
// read the same sidecar.
|
||||
|
||||
/// session ID → project display name. Empty when no sessions on screen
|
||||
/// are project-attributed.
|
||||
private(set) var sessionProjectNames: [String: String] = [:]
|
||||
/// Every project in the registry, used to populate the filter Menu.
|
||||
private(set) var allProjects: [ProjectEntry] = []
|
||||
/// Currently selected project filter.
|
||||
/// - `nil` (default): show all sessions.
|
||||
/// - `""` sentinel: show only unattributed sessions.
|
||||
/// - any other string: project name to match against `sessionProjectNames`.
|
||||
var projectFilter: String?
|
||||
|
||||
/// Sessions to actually render — applies `projectFilter` over `sessions`.
|
||||
/// Inset is O(n) which is fine at the 500-session window we load.
|
||||
var filteredSessions: [HermesSession] {
|
||||
guard let filter = projectFilter else { return sessions }
|
||||
if filter.isEmpty {
|
||||
return sessions.filter { sessionProjectNames[$0.id] == nil }
|
||||
}
|
||||
return sessions.filter { sessionProjectNames[$0.id] == filter }
|
||||
}
|
||||
|
||||
/// Project display name for a session, or nil for unattributed.
|
||||
func projectName(for session: HermesSession) -> String? {
|
||||
sessionProjectNames[session.id]
|
||||
}
|
||||
|
||||
func load() async {
|
||||
// refresh() forces a fresh snapshot on remote contexts. The DB stays
|
||||
// open after load() so selectSession()/search() can query without
|
||||
@@ -45,6 +79,30 @@ final class SessionsViewModel {
|
||||
guard opened else { return }
|
||||
sessions = await dataService.fetchSessions(limit: 500)
|
||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
|
||||
|
||||
// Load attribution + registry off the main actor in one batch so
|
||||
// 500 rows don't trigger 500 SFTP reads. Failure is silent — the
|
||||
// absence of project labels is a cosmetic degradation, not a
|
||||
// data-loss problem (matches the iOS Dashboard pattern).
|
||||
let ctx = context
|
||||
let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached {
|
||||
let attribution = SessionAttributionService(context: ctx)
|
||||
let registry = ProjectDashboardService(context: ctx).loadRegistry()
|
||||
let pathToName = Dictionary(
|
||||
uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) }
|
||||
)
|
||||
let map = attribution.load().mappings
|
||||
var names: [String: String] = [:]
|
||||
for (sessionID, path) in map {
|
||||
if let name = pathToName[path] {
|
||||
names[sessionID] = name
|
||||
}
|
||||
}
|
||||
return (names: names, projects: registry.projects)
|
||||
}.value
|
||||
sessionProjectNames = bundle.names
|
||||
allProjects = bundle.projects
|
||||
|
||||
computeStats()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ struct SessionsView: View {
|
||||
statsBar(stats)
|
||||
Divider()
|
||||
}
|
||||
if !viewModel.allProjects.isEmpty {
|
||||
filterBar
|
||||
Divider()
|
||||
}
|
||||
HSplitView {
|
||||
sessionList
|
||||
.frame(minWidth: 280, idealWidth: 320)
|
||||
@@ -104,21 +108,102 @@ struct SessionsView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(viewModel.sessions) { session in
|
||||
SessionRow(session: session, preview: viewModel.previewFor(session))
|
||||
.tag(session.id)
|
||||
.contextMenu {
|
||||
Button("Rename...") { viewModel.beginRename(session) }
|
||||
Button("Export...") { viewModel.exportSession(session) }
|
||||
Divider()
|
||||
Button("Delete...", role: .destructive) { viewModel.beginDelete(session) }
|
||||
}
|
||||
ForEach(viewModel.filteredSessions) { session in
|
||||
SessionRow(
|
||||
session: session,
|
||||
preview: viewModel.previewFor(session),
|
||||
projectName: viewModel.projectName(for: session)
|
||||
)
|
||||
.tag(session.id)
|
||||
.contextMenu {
|
||||
Button("Rename...") { viewModel.beginRename(session) }
|
||||
Button("Export...") { viewModel.exportSession(session) }
|
||||
Divider()
|
||||
Button("Delete...", role: .destructive) { viewModel.beginDelete(session) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
/// Project filter Menu shown above the list when at least one
|
||||
/// project is registered. Mirrors the Dashboard's Sessions tab on
|
||||
/// iOS, with an "Unattributed" entry for quick-chat / pre-v2.3
|
||||
/// sessions that have no project mapping.
|
||||
private var filterBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
Menu {
|
||||
Button {
|
||||
viewModel.projectFilter = nil
|
||||
} label: {
|
||||
Label("All projects", systemImage: "tray.full")
|
||||
}
|
||||
Button {
|
||||
viewModel.projectFilter = ""
|
||||
} label: {
|
||||
Label("Unattributed", systemImage: "questionmark.folder")
|
||||
}
|
||||
Divider()
|
||||
ForEach(viewModel.allProjects.sorted { $0.name < $1.name }) { project in
|
||||
Button {
|
||||
viewModel.projectFilter = project.name
|
||||
} label: {
|
||||
Label(project.name, systemImage: "folder.fill")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: filterIconName)
|
||||
Text(filterLabel)
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.tint.opacity(0.1), in: Capsule())
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
|
||||
if viewModel.projectFilter != nil {
|
||||
Button {
|
||||
viewModel.projectFilter = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Clear filter")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(viewModel.filteredSessions.count) of \(viewModel.sessions.count) shown")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private var filterIconName: String {
|
||||
viewModel.projectFilter == nil
|
||||
? "line.3.horizontal.decrease.circle"
|
||||
: "line.3.horizontal.decrease.circle.fill"
|
||||
}
|
||||
|
||||
private var filterLabel: String {
|
||||
switch viewModel.projectFilter {
|
||||
case .none: return "All projects"
|
||||
case .some(let s) where s.isEmpty: return "Unattributed"
|
||||
case .some(let s): return s
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sessionDetail: some View {
|
||||
if let session = viewModel.selectedSession {
|
||||
|
||||
Reference in New Issue
Block a user