diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f8faf9..964b9ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,19 @@ name: CI on: push: + branches: [main] pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: macos: name: macOS Build runs-on: macos-14 + timeout-minutes: 30 steps: - name: Checkout @@ -15,14 +22,26 @@ jobs: with: submodules: recursive + - name: Select Xcode 16 + timeout-minutes: 2 + run: | + XCODE_PATH="$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort | tail -n 1)" + test -n "$XCODE_PATH" + sudo xcode-select -s "$XCODE_PATH" + swift --version + - name: Install Rust + timeout-minutes: 5 run: rustup toolchain install stable --profile minimal - name: Build sidecar - run: cargo build --manifest-path skill-cli/Cargo.toml + timeout-minutes: 10 + run: cargo build --locked --manifest-path skill-cli/Cargo.toml - name: Build SwiftUI app + timeout-minutes: 10 run: swift build --package-path swift-app - name: Run Swift tests + timeout-minutes: 10 run: swift test --package-path swift-app diff --git a/docs/screenshots/matrix.jpg b/docs/screenshots/matrix.jpg index bf87ae3..7c2db92 100644 Binary files a/docs/screenshots/matrix.jpg and b/docs/screenshots/matrix.jpg differ diff --git a/skill-cli/src/main.rs b/skill-cli/src/main.rs index d15e1a7..6736ccb 100644 --- a/skill-cli/src/main.rs +++ b/skill-cli/src/main.rs @@ -8,6 +8,8 @@ use serde_json::{Value, json}; use std::collections::BTreeMap; use std::env; use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use std::process::ExitCode; @@ -557,7 +559,7 @@ async fn run() -> Result<()> { .get_installed_skill(&skill_id) .context("failed to read installed skill")? .with_context(|| format!("skill not found: {skill_id}"))?; - print_json(&ApiResponse::ok(skill)) + print_json(&ApiResponse::ok(enrich_installed_skill(skill))) } Commands::CheckUpdates { json: _ } => { let service = SkillService::new(); @@ -726,7 +728,7 @@ async fn run() -> Result<()> { ); } } - print_json(&ApiResponse::ok(installed)) + print_json(&ApiResponse::ok(enrich_installed_skill(installed))) } Commands::Update { skill_id, json: _ } => { let service = SkillService::new(); @@ -734,7 +736,7 @@ async fn run() -> Result<()> { .update_skill(&db, &skill_id) .await .with_context(|| format!("failed to update skill '{skill_id}'"))?; - print_json(&ApiResponse::ok(skill)) + print_json(&ApiResponse::ok(enrich_installed_skill(skill))) } Commands::Uninstall { skill_id, @@ -761,7 +763,7 @@ async fn run() -> Result<()> { let app_type = parse_target_app(&app)?; let skill = rehydrate_stub(&db, &skill_id, &app_type) .with_context(|| format!("failed to rehydrate skill '{skill_id}'"))?; - print_json(&ApiResponse::ok(skill)) + print_json(&ApiResponse::ok(enrich_installed_skill(skill))) } Commands::SecurityScan { skill_dir, @@ -795,7 +797,7 @@ async fn run() -> Result<()> { let app_type = parse_target_app(&app)?; let skill = SkillService::restore_from_backup(&db, &backup_id, &app_type) .with_context(|| format!("failed to restore skill backup '{backup_id}'"))?; - print_json(&ApiResponse::ok(skill)) + print_json(&ApiResponse::ok(enrich_installed_skill(skill))) } Commands::BackupDelete { backup_id, json: _ } => { SkillService::delete_backup(&backup_id) @@ -2183,6 +2185,44 @@ struct EnrichedInstalledSkill { /// "位置与链接" section 以及"链接健康" view。 #[serde(skip_serializing_if = "Option::is_none")] deployment: Option, + /// SKILL.md frontmatter that is useful for the matrix/detail UI. + #[serde(skip_serializing_if = "Option::is_none")] + manifest: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct SkillManifest { + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + license: Option, + #[serde(skip_serializing_if = "Option::is_none")] + homepage: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + required_bins: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + required_tools: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct SkillManifestTool { + name: String, + available: bool, +} + +impl SkillManifest { + fn is_empty(&self) -> bool { + self.version.is_none() + && self.author.is_none() + && self.license.is_none() + && self.homepage.is_none() + && self.required_bins.is_empty() + && self.required_tools.is_empty() + } } /// 描述一个 skill 在中心仓(SSOT)和各 AI 工具目录之间的部署形态。 @@ -2590,13 +2630,17 @@ fn npm_global_mcp_list() -> Vec { fn enrich_installed_skill(skill: InstalledSkill) -> EnrichedInstalledSkill { // CC Switch already parses SKILL.md frontmatter description (including YAML - // multi-line scalars), so reuse it for capability_summary. Only read SKILL.md - // ourselves to extract the `triggers:` field, which CC Switch does not expose. - let trigger_scenarios = installed_skill_dir(&skill) + // multi-line scalars), so reuse it for capability_summary. Read SKILL.md + // once here for UI metadata CC Switch does not expose. + let skill_markdown = installed_skill_dir(&skill) .ok() .and_then(|dir| std::fs::read_to_string(dir.join("SKILL.md")).ok()) - .map(|content| parse_skill_triggers(&content)) + .map(|content| content); + let trigger_scenarios = skill_markdown + .as_deref() + .map(parse_skill_triggers) .unwrap_or_default(); + let manifest = skill_markdown.as_deref().and_then(parse_skill_manifest); let capability_summary = skill.description.as_deref().and_then(first_sentence_of); let source_type = Some(detect_source_type(&skill)); @@ -2608,6 +2652,7 @@ fn enrich_installed_skill(skill: InstalledSkill) -> EnrichedInstalledSkill { trigger_scenarios, source_type, deployment, + manifest, } } @@ -3055,6 +3100,126 @@ fn parse_skill_triggers(content: &str) -> Vec { triggers } +fn parse_skill_manifest(content: &str) -> Option { + let Some((frontmatter, _body)) = split_frontmatter(content) else { + return None; + }; + + let mut manifest = SkillManifest::default(); + let mut in_required_bins_block = false; + + for line in frontmatter.lines() { + let trimmed = line.trim_start(); + + if in_required_bins_block { + if let Some(item) = trimmed.strip_prefix("- ") { + let bin = unquote_frontmatter_value(item.trim()); + if !bin.is_empty() { + manifest.required_bins.push(bin); + } + continue; + } + in_required_bins_block = false; + } + + let Some((key, value)) = trimmed.split_once(':') else { + continue; + }; + let key = key.trim(); + let value = value.trim(); + + match key { + "version" => assign_manifest_scalar(&mut manifest.version, value), + "author" => assign_manifest_scalar(&mut manifest.author, value), + "license" => assign_manifest_scalar(&mut manifest.license, value), + "homepage" => assign_manifest_scalar(&mut manifest.homepage, value), + "anyBins" | "requiredBins" | "bins" => { + if value.is_empty() { + in_required_bins_block = true; + } else { + manifest.required_bins.extend(parse_frontmatter_list(value)); + } + } + _ => {} + } + } + + manifest.required_bins.sort(); + manifest.required_bins.dedup(); + manifest.required_tools = manifest + .required_bins + .iter() + .map(|name| SkillManifestTool { + name: name.clone(), + available: command_available(name), + }) + .collect(); + + if manifest.is_empty() { + None + } else { + Some(manifest) + } +} + +fn assign_manifest_scalar(target: &mut Option, value: &str) { + if target.is_some() { + return; + } + let value = unquote_frontmatter_value(value); + if !value.is_empty() { + *target = Some(value); + } +} + +fn parse_frontmatter_list(value: &str) -> Vec { + let trimmed = value.trim().trim_start_matches('[').trim_end_matches(']'); + trimmed + .split(',') + .map(unquote_frontmatter_value) + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect() +} + +fn command_available(command: &str) -> bool { + let command = command.trim(); + if command.is_empty() { + return false; + } + + let explicit_path = Path::new(command); + if explicit_path.components().count() > 1 { + return is_executable(explicit_path); + } + + let Some(paths) = env::var_os("PATH") else { + return false; + }; + + env::split_paths(&paths).any(|path| is_executable(&path.join(command))) +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + has_execute_bit(&metadata) +} + +#[cfg(unix)] +fn has_execute_bit(metadata: &fs::Metadata) -> bool { + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn has_execute_bit(_metadata: &fs::Metadata) -> bool { + true +} + fn first_sentence_of(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { @@ -4221,6 +4386,89 @@ Turns fuzzy product ideas into crisp release plans. ); } + #[test] + fn parse_skill_manifest_reads_version_source_and_required_bins() { + let manifest = parse_skill_manifest( + r#"--- +name: baoyu-comic +description: Create knowledge comics. +version: 1.56.1 +author: "@dotey" +license: MIT +metadata: + openclaw: + homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic + requires: + anyBins: + - bun + - npx +--- +# Knowledge Comic Creator +"#, + ) + .expect("manifest should parse"); + + assert_eq!(manifest.version.as_deref(), Some("1.56.1")); + assert_eq!(manifest.author.as_deref(), Some("@dotey")); + assert_eq!(manifest.license.as_deref(), Some("MIT")); + assert_eq!( + manifest.homepage.as_deref(), + Some("https://github.com/JimLiu/baoyu-skills#baoyu-comic") + ); + assert_eq!(manifest.required_bins, vec!["bun", "npx"]); + assert_eq!( + manifest + .required_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["bun", "npx"] + ); + } + + #[test] + fn parse_skill_manifest_accepts_inline_required_bins() { + let manifest = parse_skill_manifest( + r#"--- +version: "2.4.1" +homepage: 'https://example.com/demo' +requiredBins: [sh, "definitely-popskill-missing-command"] +--- +"#, + ) + .expect("manifest should parse"); + + assert_eq!(manifest.version.as_deref(), Some("2.4.1")); + assert_eq!( + manifest.homepage.as_deref(), + Some("https://example.com/demo") + ); + assert_eq!( + manifest.required_bins, + vec!["definitely-popskill-missing-command", "sh"] + ); + assert_eq!( + manifest.required_tools, + vec![ + SkillManifestTool { + name: "definitely-popskill-missing-command".to_string(), + available: false, + }, + SkillManifestTool { + name: "sh".to_string(), + available: true, + } + ] + ); + } + + #[test] + fn command_available_detects_missing_commands() { + assert!(!command_available( + "definitely-popskill-missing-command-for-tests" + )); + } + #[test] fn local_agent_from_markdown_infers_category_and_title() { let root = Path::new("/tmp/agents"); diff --git a/swift-app/Sources/Popskill/App/PopskillStore.swift b/swift-app/Sources/Popskill/App/PopskillStore.swift index a8e575c..d29e7b6 100644 --- a/swift-app/Sources/Popskill/App/PopskillStore.swift +++ b/swift-app/Sources/Popskill/App/PopskillStore.swift @@ -15,6 +15,7 @@ import Observation final class PopskillStore { // ===== Data slices ===== var skills: [Skill] = [] + var packages: [CapabilityPackage] = [] var unmanagedSkills: [UnmanagedSkill] = [] var localAgents: [LocalAgent] = [] var agentTargets: [AgentTarget] = [] @@ -32,6 +33,8 @@ final class PopskillStore { var usageSummary: UsageSummary? var usageScanError: String? var usageScanInFlight: Bool = false + var updatesRefreshInFlight: Bool = false + var readmePreviewStates: [String: ReadmePreviewLoadState] = [:] // Per-slice refresh timestamps. Views call refresh*(force: false) from // .task on first appearance; if a recent refresh exists we skip the @@ -67,9 +70,14 @@ final class PopskillStore { // ===== Matrix state ===== var matrixFilter: MatrixFilter = .all var matrixTypeFilter: MatrixTypeFilter = .allTypes + var matrixSortMode: MatrixSortMode = .typeDescending /// Repo groups the user has explicitly collapsed. Set is keyed by /// `MatrixGroup.id` (== "owner/name" or "ungrouped"). var collapsedGroups: Set = [] + /// Composite packages are expanded by default so the matrix immediately + /// shows the component tree from the reference design. Users can collapse + /// noisy bundles without hiding the whole source group. + var collapsedPackageIDs: Set = [] // ===== System state ===== var lastBootstrapAt: Date? @@ -79,14 +87,16 @@ final class PopskillStore { var errorMessage: String? // ===== Services ===== - let client = SkillCLIClient() + let client: SkillCLIClient // Per-skill toggle / uninstall in-flight tracking so the matrix can dim // controls during pending IO. var pendingToggles: Set = [] var pendingUninstalls: Set = [] - init() {} + init(client: SkillCLIClient = SkillCLIClient()) { + self.client = client + } // ===== Bootstrap ===== @@ -102,12 +112,16 @@ final class PopskillStore { async let skillsTask = client.list() async let sourcesTask = client.listRepositories() async let agentsTask = client.listAgents() + async let packagesTask = loadPackagesBestEffort() + async let stubsTask = loadStubsBestEffort() do { let now = Date() self.skills = try await skillsTask self.sources = try await sourcesTask self.localAgents = try await agentsTask + self.packages = await packagesTask + self.stubs = await stubsTask self.lastBootstrapAt = now // Bootstrap counts as a fresh sources fetch — secondary views // that .task into refreshSources won't double-pull immediately. @@ -117,6 +131,22 @@ final class PopskillStore { } } + private func loadPackagesBestEffort() async -> [CapabilityPackage] { + do { + return try await client.listPackages() + } catch { + return [] + } + } + + private func loadStubsBestEffort() async -> [StubbedSkill] { + do { + return try await client.listStubs() + } catch { + return [] + } + } + // MARK: Cached refresh helpers // // Each secondary view (Sources / Updates / Backups) hits sidecar on its @@ -138,7 +168,11 @@ final class PopskillStore { } func refreshUpdates(force: Bool = false) async { + guard !updatesRefreshInFlight else { return } guard !shouldSkipRefresh(lastUpdatesRefreshAt, force: force) else { return } + updatesRefreshInFlight = true + defer { updatesRefreshInFlight = false } + do { updates = try await client.checkUpdates() lastUpdatesRefreshAt = Date() @@ -157,6 +191,20 @@ final class PopskillStore { } } + func refreshStubs() async { + do { + stubs = try await client.listStubs() + } catch { + errorMessage = error.localizedDescription + } + } + + func upsertStub(_ stub: StubbedSkill) { + stubs.removeAll { $0.skill.id == stub.skill.id } + stubs.append(stub) + stubs.sort { $0.stubbedAt > $1.stubbedAt } + } + private func shouldSkipRefresh(_ lastRefresh: Date?, force: Bool) -> Bool { guard !force, let lastRefresh else { return false } return Date().timeIntervalSince(lastRefresh) < Self.refreshTTL @@ -176,9 +224,16 @@ final class PopskillStore { /// rather than stored so toggle / install / uninstall actions only need /// to mutate `skills` / `localAgents` and the matrix follows. var capabilities: [MatrixCapability] { - skills.map(MatrixCapability.fromSkill) + localAgents.map(MatrixCapability.fromAgent) + compositePackages.map { MatrixCapability.fromPackage($0, skills: skills) } + + skills.map(MatrixCapability.fromSkill) + + localAgents.map(MatrixCapability.fromAgent) } + var compositePackages: [CapabilityPackage] { + packages.filter { $0.type == .composite } + } + + var bundleCount: Int { compositePackages.count } var pendingUpdateCount: Int { updates.count } var brokenLinkCount: Int { linkHealth?.summary.broken ?? 0 } var okLinkCount: Int { linkHealth?.summary.ok ?? 0 } @@ -189,10 +244,57 @@ final class PopskillStore { var isSearchActive: Bool { !trimmedSearch.isEmpty } + func showMatrix( + filter: MatrixFilter = .all, + typeFilter: MatrixTypeFilter = .allTypes, + clearSearch: Bool = true + ) { + currentSelection = .matrix + matrixFilter = filter + matrixTypeFilter = typeFilter + inspectorOpen = false + selectedSkillID = nil + if clearSearch { + searchText = "" + } + } + + func showSettings() { + currentSelection = .settings + } + + func matrixFilterCount(_ filter: MatrixFilter) -> Int { + capabilities.filter { filter.includes(capability: $0, store: self) }.count + } + + func matrixTypeFilterCount(_ typeFilter: MatrixTypeFilter) -> Int { + capabilities.filter { typeFilter.includes(capability: $0) }.count + } + + func matrixShortcutCounts() -> MatrixShortcutCounts { + let currentCapabilities = capabilities + let filterCounts = Dictionary(uniqueKeysWithValues: MatrixFilter.allCases.map { filter in + (filter, currentCapabilities.filter { filter.includes(capability: $0, store: self) }.count) + }) + let typeCounts = Dictionary(uniqueKeysWithValues: MatrixTypeFilter.allCases.map { filter in + (filter, currentCapabilities.filter { filter.includes(capability: $0) }.count) + }) + return MatrixShortcutCounts( + capabilityCount: currentCapabilities.count, + filterCounts: filterCounts, + typeCounts: typeCounts + ) + } + /// O(1) update lookup for matrix rows and filters. `SkillUpdateInfo.id` /// may be scoped ("owner/name:skill") or path-like, so both the full id /// and its useful suffixes are indexed once when `updates` changes. func hasPendingUpdate(for capability: MatrixCapability) -> Bool { + if let package = capability.package { + return updates.contains { update in + package.matchingSkillComponent(for: update) != nil + } + } guard let skillID = capability.underlyingSkillID else { return false } return updateIdentifierCandidates(for: skillID).contains { updateSkillIDs.contains($0) } } @@ -220,11 +322,62 @@ final class PopskillStore { inspectorOpen = true } + func togglePackageExpansion(_ packageID: String) { + if collapsedPackageIDs.contains(packageID) { + collapsedPackageIDs.remove(packageID) + } else { + collapsedPackageIDs.insert(packageID) + } + } + + func skill(for component: PackageComponent) -> Skill? { + skills.first { component.matchesSkill($0) } + } + func closeInspector() { inspectorOpen = false selectedSkillID = nil } + // MARK: README previews + + func readmePreviewState(for skill: Skill) -> ReadmePreviewLoadState? { + readmePreviewStates[skill.id] + } + + func loadReadmePreview(for skill: Skill, force: Bool = false) async { + if !force { + switch readmePreviewStates[skill.id] { + case .loading, .loaded: + return + case .failed, .none: + break + } + } + + guard let readmeURL = skill.markdownURL else { + readmePreviewStates[skill.id] = .failed(ReadmePreviewError.missing.localizedDescription) + return + } + + let skillID = skill.id + let skillName = skill.name + readmePreviewStates[skillID] = .loading + + do { + let preview = try await Task.detached(priority: .utility) { + try ReadmePreview.load( + skillID: skillID, + skillName: skillName, + readmeURL: readmeURL + ) + }.value + readmePreviewStates[skillID] = .loaded(preview) + } catch { + readmePreviewStates[skillID] = .failed(error.localizedDescription) + } + } + // MARK: Usage scanner /// Walk `~/.claude/projects/**/*.jsonl` off the main thread and post the @@ -253,3 +406,27 @@ final class PopskillStore { Set(updates.flatMap(\.normalizedIdentifierCandidates)) } } + +struct MatrixShortcutCounts: Equatable { + let capabilityCount: Int + private let filterCounts: [MatrixFilter: Int] + private let typeCounts: [MatrixTypeFilter: Int] + + init( + capabilityCount: Int, + filterCounts: [MatrixFilter: Int], + typeCounts: [MatrixTypeFilter: Int] + ) { + self.capabilityCount = capabilityCount + self.filterCounts = filterCounts + self.typeCounts = typeCounts + } + + func count(for filter: MatrixFilter) -> Int { + filterCounts[filter] ?? 0 + } + + func count(for filter: MatrixTypeFilter) -> Int { + typeCounts[filter] ?? 0 + } +} diff --git a/swift-app/Sources/Popskill/Components/CommonViews.swift b/swift-app/Sources/Popskill/Components/CommonViews.swift index a71404e..164d13e 100644 --- a/swift-app/Sources/Popskill/Components/CommonViews.swift +++ b/swift-app/Sources/Popskill/Components/CommonViews.swift @@ -166,6 +166,7 @@ struct SummaryMetric: View { struct InitialAvatarView: View { let name: String let identifier: String + var size: CGFloat = 44 private var initial: String { name.trimmingCharacters(in: .whitespacesAndNewlines).first.map { String($0).uppercased() } ?? "S" @@ -193,10 +194,10 @@ struct InitialAvatarView: View { RoundedRectangle(cornerRadius: PopskillRadius.smallCard) .fill(color.gradient) Text(initial) - .font(.system(size: 20, weight: .bold)) + .font(.system(size: max(10, size * 0.45), weight: .bold)) .foregroundStyle(.white) } - .frame(width: 44, height: 44) + .frame(width: size, height: size) } } diff --git a/swift-app/Sources/Popskill/Design/PopskillColors.swift b/swift-app/Sources/Popskill/Design/PopskillColors.swift index f0cf7ae..4fd935e 100644 --- a/swift-app/Sources/Popskill/Design/PopskillColors.swift +++ b/swift-app/Sources/Popskill/Design/PopskillColors.swift @@ -59,10 +59,10 @@ enum PopskillSpacing { } enum PopskillRadius { - static let button: CGFloat = 8 - static let smallCard: CGFloat = 12 - static let card: CGFloat = 18 - static let largeCard: CGFloat = 20 + static let button: CGFloat = 6 + static let smallCard: CGFloat = 8 + static let card: CGFloat = 8 + static let largeCard: CGFloat = 12 } enum PopskillShadow { diff --git a/swift-app/Sources/Popskill/Models/MatrixCapability.swift b/swift-app/Sources/Popskill/Models/MatrixCapability.swift index b4561dc..c8b1264 100644 --- a/swift-app/Sources/Popskill/Models/MatrixCapability.swift +++ b/swift-app/Sources/Popskill/Models/MatrixCapability.swift @@ -1,11 +1,11 @@ import Foundation -/// Five capability modalities Popskill surfaces in the matrix. Order in -/// `CaseIterable` mirrors the type-filter chip row in `MatrixView` (skill → -/// agent → cli → mcp → config). v0.4 ships skill + agent fully; cli / mcp / -/// config are placeholders that the matrix renders an "empty for now" hint -/// for until later sprints wire their sidecar discovery. +/// Capability modalities Popskill surfaces in the matrix. Order in +/// `CaseIterable` mirrors the type-filter chip row in `MatrixView`. +/// Bundles are first-class package rows backed by `package-list`; skill and +/// agent rows keep the existing direct management flows. enum CapabilityKind: String, Codable, CaseIterable, Identifiable { + case bundle case skill case agent case cli @@ -16,6 +16,7 @@ enum CapabilityKind: String, Codable, CaseIterable, Identifiable { var titleKey: String { switch self { + case .bundle: return "matrix.type.bundle" case .skill: return "matrix.type.skill" case .agent: return "matrix.type.agent" case .cli: return "matrix.type.cli" @@ -26,6 +27,7 @@ enum CapabilityKind: String, Codable, CaseIterable, Identifiable { var symbol: String { switch self { + case .bundle: return "shippingbox.fill" case .skill: return "square.grid.3x3.fill" case .agent: return "person.crop.square" case .cli: return "terminal" @@ -35,6 +37,14 @@ enum CapabilityKind: String, Codable, CaseIterable, Identifiable { } } +struct CapabilityAppCoverage: Equatable { + let enabled: Int + let total: Int + + var isEnabled: Bool { enabled > 0 } + var label: String { "\(enabled)/\(total)" } +} + /// Unified row model the matrix renders against. Wraps `Skill` / `LocalAgent` /// / future `CLITool` / `MCPServer` / `ConfigEntry` without exposing their /// individual shapes to view code. Toggle actions look up the underlying @@ -59,10 +69,60 @@ struct MatrixCapability: Identifiable, Equatable { let triggerScenarios: [String]? let underlyingSkillID: String? let underlyingAgentID: String? + let underlyingPackageID: String? + let package: CapabilityPackage? + let appCoverage: [TargetApp: CapabilityAppCoverage] + + init( + id: String, + kind: CapabilityKind, + name: String, + summary: String?, + sourceLabel: String, + sourceType: String?, + repoOwner: String?, + repoName: String?, + apps: SkillApps, + deployment: SkillDeployment?, + directory: String, + installedAt: Int?, + updatedAt: Int?, + sizeBytes: UInt64?, + triggerScenarios: [String]?, + underlyingSkillID: String?, + underlyingAgentID: String?, + underlyingPackageID: String? = nil, + package: CapabilityPackage? = nil, + appCoverage: [TargetApp: CapabilityAppCoverage] = [:] + ) { + self.id = id + self.kind = kind + self.name = name + self.summary = summary + self.sourceLabel = sourceLabel + self.sourceType = sourceType + self.repoOwner = repoOwner + self.repoName = repoName + self.apps = apps + self.deployment = deployment + self.directory = directory + self.installedAt = installedAt + self.updatedAt = updatedAt + self.sizeBytes = sizeBytes + self.triggerScenarios = triggerScenarios + self.underlyingSkillID = underlyingSkillID + self.underlyingAgentID = underlyingAgentID + self.underlyingPackageID = underlyingPackageID + self.package = package + self.appCoverage = appCoverage + } /// Source URL is reusable across kinds — the matrix uses it for the /// "Open Source" menu item. var sourceURL: URL? { + if let packageURL = package?.sourceURL { + return packageURL + } if let owner = repoOwner, let name = repoName, !owner.isEmpty, !name.isEmpty { return URL(string: "https://github.com/\(owner)/\(name)") } @@ -77,6 +137,34 @@ struct MatrixCapability: Identifiable, Equatable { kind == .skill } + var hasBrokenLink: Bool { + deployment?.hasBrokenLink == true + } + + func hasBrokenLinks(in skills: [Skill]) -> Bool { + hasBrokenLink || package?.hasBrokenLinks(in: skills) == true + } + + func matchesSearch(query: SearchTextKey) -> Bool { + guard !query.isEmpty else { return true } + + if Self.matches(searchFields, query: query) { + return true + } + + guard let package else { + return false + } + + return Self.matches(package.searchFields, query: query) + || package.components.all.contains { component in + Self.matches(component.searchFields, query: query) + } + || package.configSchema.contains { field in + Self.matches(field.searchFields, query: query) + } + } + static func capabilityID(kind: CapabilityKind, rawID: String) -> String { "\(kind.rawValue):\(rawID)" } @@ -89,6 +177,10 @@ struct MatrixCapability: Identifiable, Equatable { capabilityID(kind: .agent, rawID: agentID) } + static func packageCapabilityID(for packageID: String) -> String { + capabilityID(kind: .bundle, rawID: packageID) + } + static func toggleKey(capabilityID: String, app: TargetApp) -> String { "\(capabilityID)|\(app.rawValue)" } @@ -96,9 +188,67 @@ struct MatrixCapability: Identifiable, Equatable { static func skillToggleKey(for skillID: String, app: TargetApp) -> String { toggleKey(capabilityID: skillCapabilityID(for: skillID), app: app) } + + private var searchFields: [String] { + var fields = [ + id, + kind.rawValue, + name, + summary ?? "", + sourceLabel, + sourceType ?? "", + repoOwner ?? "", + repoName ?? "", + directory + ] + fields.append(contentsOf: kind.searchAliases) + fields.append(contentsOf: triggerScenarios ?? []) + fields.append(contentsOf: TargetApp.supported.flatMap { app in + apps.isEnabled(app) ? app.searchAliases : [] + }) + return fields + } + + private static func matches(_ fields: [String], query: SearchTextKey) -> Bool { + fields.contains { SearchTextNormalizer.matches($0, query: query) } + } } extension MatrixCapability { + static func fromPackage(_ package: CapabilityPackage, skills: [Skill]) -> MatrixCapability { + let coverage = package.appCoverage(using: skills) + let appState = SkillApps( + claude: coverage[.claude]?.isEnabled == true, + codex: coverage[.codex]?.isEnabled == true, + gemini: coverage[.gemini]?.isEnabled == true, + opencode: coverage[.opencode]?.isEnabled == true, + hermes: coverage[.hermes]?.isEnabled == true + ) + + return MatrixCapability( + id: packageCapabilityID(for: package.id), + kind: .bundle, + name: package.name, + summary: package.summary, + sourceLabel: package.sourceLabel, + sourceType: package.source.kind, + repoOwner: package.source.repoOwner, + repoName: package.source.repoName, + apps: appState, + deployment: nil, + directory: package.source.location, + installedAt: package.lifecycle?.installedAt, + updatedAt: package.lifecycle?.updatedAt, + sizeBytes: nil, + triggerScenarios: nil, + underlyingSkillID: nil, + underlyingAgentID: nil, + underlyingPackageID: package.id, + package: package, + appCoverage: coverage + ) + } + static func fromSkill(_ skill: Skill) -> MatrixCapability { MatrixCapability( id: skillCapabilityID(for: skill.id), @@ -148,3 +298,91 @@ extension MatrixCapability { ) } } + +private extension CapabilityKind { + var searchAliases: [String] { + switch self { + case .bundle: ["bundle", "package", "suite", "套装", "能力包"] + case .skill: ["skill", "技能", "能力"] + case .agent: ["agent", "智能体"] + case .cli: ["cli", "command line", "命令行"] + case .mcp: ["mcp", "server", "服务"] + case .config: ["config", "settings", "配置", "设置"] + } + } +} + +private extension TargetApp { + var searchAliases: [String] { + switch self { + case .claude: ["Claude", "Claude Code", "CC"] + case .codex: ["Codex", "CDX"] + case .gemini: ["Gemini"] + case .opencode: ["OpenCode"] + case .hermes: ["Hermes"] + } + } +} + +private extension CapabilityPackage { + var searchFields: [String] { + [ + id, + type.rawValue, + type.title, + health.rawValue, + health.title, + name, + vendor ?? "", + summary, + source.kind, + source.location, + source.updateStrategy, + source.repoOwner ?? "", + source.repoName ?? "", + source.repoBranch ?? "", + source.readmeUrl ?? "", + installed ? "installed 已装 active" : "missing 未装 inactive", + "components 组件" + ] + } +} + +private extension PackageComponent { + var searchFields: [String] { + [ + id, + name, + kind, + status, + statusAliases, + required ? "required 必需" : "optional 可选", + installed ? "installed 已装" : "missing 未装", + location ?? "" + ] + } + + private var statusAliases: String { + switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "stub": "stub 占位" + case "registry-reference": "registry reference 注册表" + case "detected": "detected 检测到" + case "installed": "installed 已装" + case "declared": "declared 声明" + case "available": "available 可用" + default: "" + } + } +} + +private extension PackageConfigField { + var searchFields: [String] { + [ + id, + label, + storage, + required ? "required 必需" : "optional 可选", + secret ? "secret 密钥" : "" + ] + } +} diff --git a/swift-app/Sources/Popskill/Models/MatrixVersionFormatter.swift b/swift-app/Sources/Popskill/Models/MatrixVersionFormatter.swift new file mode 100644 index 0000000..8abd58e --- /dev/null +++ b/swift-app/Sources/Popskill/Models/MatrixVersionFormatter.swift @@ -0,0 +1,44 @@ +import Foundation + +enum MatrixVersionFormatter { + static func value(manifestVersion: String?, contentHash: String?, updatedAt: Int?) -> String? { + if let version = semanticVersion(manifestVersion) { + return version + } + return value(contentHash: contentHash, updatedAt: updatedAt) + } + + static func value(contentHash: String?, updatedAt: Int?) -> String? { + if let hash = shortHash(contentHash) { + return hash + } + if let updatedAt, updatedAt > 0 { + return dateString(updatedAt) + } + return nil + } + + static func shortHash(_ hash: String?) -> String? { + guard let hash = hash?.trimmingCharacters(in: .whitespacesAndNewlines), + !hash.isEmpty else { + return nil + } + return String(hash.prefix(7)) + } + + static func semanticVersion(_ version: String?) -> String? { + guard let version = version?.trimmingCharacters(in: .whitespacesAndNewlines), + !version.isEmpty else { + return nil + } + return version.hasPrefix("v") ? version : "v\(version)" + } + + private static func dateString(_ timestamp: Int) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.string(from: date) + } +} diff --git a/swift-app/Sources/Popskill/Models/PackageSearchScorer.swift b/swift-app/Sources/Popskill/Models/PackageSearchScorer.swift new file mode 100644 index 0000000..77c96b1 --- /dev/null +++ b/swift-app/Sources/Popskill/Models/PackageSearchScorer.swift @@ -0,0 +1,84 @@ +import Foundation + +struct PackageSearchHit: Equatable { + let score: Int + let matchedComponents: [String] + let matchedOnName: Bool + + static let recent = PackageSearchHit(score: 0, matchedComponents: [], matchedOnName: false) +} + +enum PackageSearchScorer { + static func score(package: CapabilityPackage, query: String) -> PackageSearchHit? { + let q = SearchTextNormalizer.key(query) + guard !q.isEmpty else { + return nil + } + + var score = 0 + var matchedOnName = false + var matchedComponents: [String] = [] + + let nameScore = scoreText(package.name, query: q, exact: 1_000, prefix: 760, contains: 430) + if nameScore > 0 { + score += nameScore + matchedOnName = true + } + + score += scoreText(package.id, query: q, exact: 420, prefix: 260, contains: 160) + score += scoreText(package.vendor ?? "", query: q, exact: 340, prefix: 220, contains: 120) + score += scoreText(package.source.location, query: q, exact: 360, prefix: 240, contains: 140) + score += scoreText(package.summary, query: q, exact: 180, prefix: 120, contains: 70) + + if ["bundle", "package", "suite", "套装"].contains(q.separated) { + score += package.type == .composite ? 220 : 80 + } + + var seenComponents: Set = [] + for component in package.components.all { + let componentScore = scoreComponent(component, query: q) + guard componentScore > 0 else { continue } + score += component.installed ? componentScore : max(20, componentScore - 35) + + let label = component.name.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedLabel = SearchTextNormalizer.key(label).compact + if !label.isEmpty, !seenComponents.contains(normalizedLabel) { + seenComponents.insert(normalizedLabel) + matchedComponents.append(label) + } + } + + guard score > 0 else { + return nil + } + + return PackageSearchHit( + score: score, + matchedComponents: Array(matchedComponents.prefix(3)), + matchedOnName: matchedOnName + ) + } + + private static func scoreComponent(_ component: PackageComponent, query: SearchTextKey) -> Int { + scoreText(component.name, query: query, exact: 260, prefix: 190, contains: 120) + + scoreText(component.id, query: query, exact: 220, prefix: 150, contains: 90) + + scoreText(component.kind, query: query, exact: 140, prefix: 90, contains: 40) + + scoreText(component.location ?? "", query: query, exact: 180, prefix: 120, contains: 80) + + scoreText(component.status, query: query, exact: 80, prefix: 50, contains: 25) + } + + private static func scoreText( + _ text: String, + query: SearchTextKey, + exact: Int, + prefix: Int, + contains: Int + ) -> Int { + let value = SearchTextNormalizer.key(text) + guard !value.isEmpty else { return 0 } + if value.equals(query) { return exact } + if value.hasPrefix(query) { return prefix } + if value.contains(query) { return contains } + return 0 + } +} diff --git a/swift-app/Sources/Popskill/Models/ReadmePreview.swift b/swift-app/Sources/Popskill/Models/ReadmePreview.swift new file mode 100644 index 0000000..74f4e8e --- /dev/null +++ b/swift-app/Sources/Popskill/Models/ReadmePreview.swift @@ -0,0 +1,145 @@ +import Foundation + +struct ReadmePreview: Equatable, Sendable { + struct Excerpt: Equatable, Sendable { + let text: String + let truncated: Bool + } + + let skillID: String + let skillName: String + let url: URL + let excerpt: String + let truncated: Bool + + static let maxBytes = 24 * 1024 + static let maxCharacters = 1_600 + static let maxLines = 26 + + static func load(skillID: String, skillName: String, readmeURL: URL) throws -> ReadmePreview { + guard FileManager.default.fileExists(atPath: readmeURL.path) else { + throw ReadmePreviewError.missing + } + + let handle = try FileHandle(forReadingFrom: readmeURL) + defer { try? handle.close() } + + let data = try handle.read(upToCount: maxBytes) ?? Data() + let content = String(decoding: data, as: UTF8.self) + let excerpt = makeExcerpt(from: content) + + guard !excerpt.text.isEmpty else { + throw ReadmePreviewError.empty + } + + return ReadmePreview( + skillID: skillID, + skillName: skillName, + url: readmeURL, + excerpt: excerpt.text, + truncated: excerpt.truncated || data.count >= maxBytes + ) + } + + static func makeExcerpt( + from content: String, + maxCharacters: Int = Self.maxCharacters, + maxLines: Int = Self.maxLines + ) -> Excerpt { + let normalized = content.replacingOccurrences(of: "\r\n", with: "\n") + let rawLines = normalized + .replacingOccurrences(of: "\r", with: "\n") + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + let contentLines = dropYAMLFrontmatter(from: rawLines) + let compactedLines = compactBlankRuns(in: contentLines) + .drop { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + var selected: [String] = [] + var currentCharacters = 0 + var truncated = false + + for line in compactedLines { + let cleanLine = line.trimmingCharacters(in: .whitespaces) + let additionalCharacters = cleanLine.count + (selected.isEmpty ? 0 : 1) + + guard selected.count < maxLines else { + truncated = true + break + } + guard currentCharacters + additionalCharacters <= maxCharacters else { + truncated = true + break + } + + selected.append(cleanLine) + currentCharacters += additionalCharacters + } + + let excerpt = selected + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + + return Excerpt(text: excerpt, truncated: truncated) + } + + private static func dropYAMLFrontmatter(from lines: [String]) -> ArraySlice { + guard lines.first?.trimmingCharacters(in: .whitespacesAndNewlines) == "---" else { + return ArraySlice(lines) + } + + let searchRange = lines.dropFirst().prefix(40) + guard let closingIndex = searchRange.firstIndex(where: { line in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed == "---" || trimmed == "..." + }) else { + return ArraySlice(lines) + } + + let nextIndex = lines.index(after: closingIndex) + guard nextIndex < lines.endIndex else { + return [] + } + return lines[nextIndex...] + } + + private static func compactBlankRuns(in lines: ArraySlice) -> [String] { + var compacted: [String] = [] + var previousWasBlank = false + + for line in lines { + let isBlank = line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if isBlank { + guard !previousWasBlank else { continue } + compacted.append("") + } else { + compacted.append(line) + } + previousWasBlank = isBlank + } + + while compacted.last?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { + compacted.removeLast() + } + return compacted + } +} + +enum ReadmePreviewLoadState: Equatable { + case loading + case loaded(ReadmePreview) + case failed(String) +} + +enum ReadmePreviewError: LocalizedError { + case missing + case empty + + var errorDescription: String? { + switch self { + case .missing: + "SKILL.md was not found." + case .empty: + "SKILL.md did not contain readable text." + } + } +} diff --git a/swift-app/Sources/Popskill/Models/SearchTextNormalizer.swift b/swift-app/Sources/Popskill/Models/SearchTextNormalizer.swift new file mode 100644 index 0000000..7899375 --- /dev/null +++ b/swift-app/Sources/Popskill/Models/SearchTextNormalizer.swift @@ -0,0 +1,67 @@ +import Foundation + +struct SearchTextKey: Equatable { + let separated: String + let compact: String + + var isEmpty: Bool { + separated.isEmpty && compact.isEmpty + } + + func equals(_ query: SearchTextKey) -> Bool { + separated == query.separated || (!compact.isEmpty && compact == query.compact) + } + + func hasPrefix(_ query: SearchTextKey) -> Bool { + (!query.separated.isEmpty && separated.hasPrefix(query.separated)) + || (!query.compact.isEmpty && compact.hasPrefix(query.compact)) + } + + func contains(_ query: SearchTextKey) -> Bool { + (!query.separated.isEmpty && separated.contains(query.separated)) + || (!query.compact.isEmpty && compact.contains(query.compact)) + } +} + +enum SearchTextNormalizer { + static func key(_ value: String) -> SearchTextKey { + let folded = value + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + .lowercased() + + var scalars: [UnicodeScalar] = [] + scalars.reserveCapacity(folded.unicodeScalars.count) + + var previousWasSeparator = true + for scalar in folded.unicodeScalars { + if isSearchScalar(scalar) { + scalars.append(scalar) + previousWasSeparator = false + } else if !previousWasSeparator { + scalars.append(" ") + previousWasSeparator = true + } + } + + if scalars.last == " " { + scalars.removeLast() + } + + let separated = String(String.UnicodeScalarView(scalars)) + return SearchTextKey( + separated: separated, + compact: separated.replacingOccurrences(of: " ", with: "") + ) + } + + static func matches(_ text: String, query: SearchTextKey) -> Bool { + key(text).contains(query) + } + + private static func isSearchScalar(_ scalar: UnicodeScalar) -> Bool { + CharacterSet.alphanumerics.contains(scalar) + || (0x4E00...0x9FFF).contains(scalar.value) + || (0x3400...0x4DBF).contains(scalar.value) + || (0xF900...0xFAFF).contains(scalar.value) + } +} diff --git a/swift-app/Sources/Popskill/Models/SkillGrouping.swift b/swift-app/Sources/Popskill/Models/SkillGrouping.swift index a84eb03..09a68c5 100644 --- a/swift-app/Sources/Popskill/Models/SkillGrouping.swift +++ b/swift-app/Sources/Popskill/Models/SkillGrouping.swift @@ -31,25 +31,73 @@ struct CapabilitySection: Identifiable, Equatable { var totalCount: Int { groups.reduce(0) { $0 + $1.capabilities.count } } } +enum MatrixSortMode: String, CaseIterable, Identifiable { + case typeDescending + case typeAscending + case nameAscending + case nameDescending + case callsDescending + case tokensDescending + case recentDescending + + var id: String { rawValue } + + var titleKey: String { + switch self { + case .typeDescending: return "matrix.sort.typeDescending" + case .typeAscending: return "matrix.sort.typeAscending" + case .nameAscending: return "matrix.sort.nameAscending" + case .nameDescending: return "matrix.sort.nameDescending" + case .callsDescending: return "matrix.sort.callsDescending" + case .tokensDescending: return "matrix.sort.tokensDescending" + case .recentDescending: return "matrix.sort.recentDescending" + } + } + + var symbolName: String { + switch self { + case .typeDescending: return "arrow.down" + case .typeAscending: return "arrow.up" + case .nameAscending: return "textformat.abc" + case .nameDescending: return "textformat.abc.dottedunderline" + case .callsDescending: return "phone.arrow.up.right" + case .tokensDescending: return "number" + case .recentDescending: return "clock.arrow.circlepath" + } + } +} + enum SkillGrouping { static let ungroupedID = "ungrouped" /// Group capabilities first by `CapabilityKind`, then by `owner/name` - /// source bucket. Inside each bucket capabilities sort by name - /// (case-insensitive). Ungrouped is always pinned at the bottom of each - /// kind section, and kinds without any capabilities are dropped. - static func sections(_ capabilities: [MatrixCapability]) -> [CapabilitySection] { + /// source bucket. The active matrix sort chooses the row order inside + /// each bucket. Ungrouped is always pinned at the bottom of each kind + /// section, and kinds without any capabilities are dropped. + static func sections( + _ capabilities: [MatrixCapability], + sort: MatrixSortMode = .typeDescending, + usageIndex: MatrixUsageIndex? = nil + ) -> [CapabilitySection] { let byKind: [CapabilityKind: [MatrixCapability]] = Dictionary(grouping: capabilities, by: \.kind) - return CapabilityKind.allCases.compactMap { kind in + let kindOrder: [CapabilityKind] = sort == .typeAscending + ? Array(CapabilityKind.allCases.reversed()) + : CapabilityKind.allCases + + return kindOrder.compactMap { kind in guard let bucket = byKind[kind], !bucket.isEmpty else { return nil } - return CapabilitySection(kind: kind, groups: group(bucket)) + return CapabilitySection(kind: kind, groups: group(bucket, sort: sort, usageIndex: usageIndex)) } } /// Group a homogeneous capability bucket by `owner/name`. Kept as a /// public helper for tests + the special-case "skills only" view in /// SpotlightView. - static func group(_ capabilities: [MatrixCapability]) -> [MatrixGroup] { + static func group( + _ capabilities: [MatrixCapability], + sort: MatrixSortMode = .typeDescending, + usageIndex: MatrixUsageIndex? = nil + ) -> [MatrixGroup] { var buckets: [String: (owner: String?, name: String?, capabilities: [MatrixCapability])] = [:] for capability in capabilities { @@ -65,9 +113,7 @@ enum SkillGrouping { } let groups = buckets.map { key, value -> MatrixGroup in - let sorted = value.capabilities.sorted { lhs, rhs in - lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } + let sorted = sortCapabilities(value.capabilities, sort: sort, usageIndex: usageIndex) return MatrixGroup(id: key, owner: value.owner, name: value.name, capabilities: sorted) } @@ -82,6 +128,65 @@ enum SkillGrouping { return ungroupedID } + private static func sortCapabilities( + _ capabilities: [MatrixCapability], + sort: MatrixSortMode, + usageIndex: MatrixUsageIndex? + ) -> [MatrixCapability] { + capabilities.sorted { lhs, rhs in + switch sort { + case .nameDescending: + return nameCompare(lhs, rhs) == .orderedDescending + case .callsDescending: + let lhsUsage = usage(for: lhs, usageIndex: usageIndex) + let rhsUsage = usage(for: rhs, usageIndex: usageIndex) + if lhsUsage.calls != rhsUsage.calls { return lhsUsage.calls > rhsUsage.calls } + if lhsUsage.tokens != rhsUsage.tokens { return lhsUsage.tokens > rhsUsage.tokens } + return nameCompare(lhs, rhs) == .orderedAscending + case .tokensDescending: + let lhsUsage = usage(for: lhs, usageIndex: usageIndex) + let rhsUsage = usage(for: rhs, usageIndex: usageIndex) + if lhsUsage.tokens != rhsUsage.tokens { return lhsUsage.tokens > rhsUsage.tokens } + if lhsUsage.calls != rhsUsage.calls { return lhsUsage.calls > rhsUsage.calls } + return nameCompare(lhs, rhs) == .orderedAscending + case .recentDescending: + let lhsUsage = usage(for: lhs, usageIndex: usageIndex) + let rhsUsage = usage(for: rhs, usageIndex: usageIndex) + if lhsUsage.lastUsedAt != rhsUsage.lastUsedAt { + if let lhsDate = lhsUsage.lastUsedAt, let rhsDate = rhsUsage.lastUsedAt { + return lhsDate > rhsDate + } + return lhsUsage.lastUsedAt != nil + } + if lhsUsage.calls != rhsUsage.calls { return lhsUsage.calls > rhsUsage.calls } + return nameCompare(lhs, rhs) == .orderedAscending + case .typeAscending, .typeDescending, .nameAscending: + return nameCompare(lhs, rhs) == .orderedAscending + } + } + } + + private static func nameCompare(_ lhs: MatrixCapability, _ rhs: MatrixCapability) -> ComparisonResult { + lhs.name.localizedCaseInsensitiveCompare(rhs.name) + } + + private static func usage( + for capability: MatrixCapability, + usageIndex: MatrixUsageIndex? + ) -> (calls: Int, tokens: Int64, lastUsedAt: Date?) { + if let packageID = capability.underlyingPackageID, + let snapshot = usageIndex?.packageSnapshot(for: packageID) { + return (snapshot.usageEvents, snapshot.totalTokens, snapshot.lastUsedAt) + } + + if let skillID = capability.underlyingSkillID, + let snapshot = usageIndex?.skillSnapshot(for: skillID) { + return (snapshot.usageEvents, snapshot.totalTokens, snapshot.lastUsedAt) + } + + return (0, 0, nil) + } + private static func areInOrder(_ lhs: MatrixGroup, _ rhs: MatrixGroup) -> Bool { if lhs.id == ungroupedID { return false } if rhs.id == ungroupedID { return true } diff --git a/swift-app/Sources/Popskill/Models/SkillModels.swift b/swift-app/Sources/Popskill/Models/SkillModels.swift index 59b32f8..3d30e15 100644 --- a/swift-app/Sources/Popskill/Models/SkillModels.swift +++ b/swift-app/Sources/Popskill/Models/SkillModels.swift @@ -132,6 +132,8 @@ struct Skill: Identifiable, Codable, Equatable { /// 来源类型("github" | "npm" | "brew" | "pip" | "builtin"),sidecar 推断。 /// Swift 端在 ViewModel 里可做更精细的二次推断(如 sourceShort 解析)。 var sourceType: String? = nil + /// SKILL.md frontmatter surfaced by the sidecar for fast matrix/detail UI. + var manifest: SkillManifest? = nil /// 真身 + 各 AI 工具 symlink 状态。喂给 Inspector "位置与链接" 与 链接健康 view。 var deployment: SkillDeployment? = nil var lastUsedAt: Int? = nil @@ -145,7 +147,12 @@ struct Skill: Identifiable, Codable, Equatable { } var sourceURL: URL? { - explicitOrRepositoryURL(readmeUrl: readmeUrl, repoOwner: repoOwner, repoName: repoName) + explicitOrRepositoryURL( + readmeUrl: readmeUrl, + fallbackExplicitURL: manifest?.homepage, + repoOwner: repoOwner, + repoName: repoName + ) } var markdownURL: URL? { @@ -157,6 +164,10 @@ struct Skill: Identifiable, Codable, Equatable { TargetApp.supported.filter { apps.isEnabled($0) }.count } + var hasBrokenLink: Bool { + deployment?.hasBrokenLink == true + } + var localStoreURL: URL { URL(fileURLWithPath: NSHomeDirectory()) .appendingPathComponent(".cc-switch") @@ -213,6 +224,98 @@ struct Skill: Identifiable, Codable, Equatable { private static func normalizedAttributionIdentifier(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + + func usageSnapshot(using summary: UsageSummary?) -> SkillUsageSnapshot? { + guard let summary else { + return nil + } + + var snapshot = SkillUsageSnapshot() + for stat in summary.thirtyDaySkillStats where matchesAttributionSkill(stat.skillID) { + snapshot.add(stat) + } + return snapshot + } +} + +struct SkillManifest: Codable, Equatable { + let version: String? + let author: String? + let license: String? + let homepage: String? + let requiredBins: [String] + let requiredTools: [SkillRequiredTool] + + private enum CodingKeys: String, CodingKey { + case version + case author + case license + case homepage + case requiredBins + case requiredTools + } + + init( + version: String? = nil, + author: String? = nil, + license: String? = nil, + homepage: String? = nil, + requiredBins: [String] = [], + requiredTools: [SkillRequiredTool] = [] + ) { + self.version = version + self.author = author + self.license = license + self.homepage = homepage + self.requiredBins = requiredBins + self.requiredTools = requiredTools + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(String.self, forKey: .version) + author = try container.decodeIfPresent(String.self, forKey: .author) + license = try container.decodeIfPresent(String.self, forKey: .license) + homepage = try container.decodeIfPresent(String.self, forKey: .homepage) + requiredBins = try container.decodeIfPresent([String].self, forKey: .requiredBins) ?? [] + requiredTools = try container.decodeIfPresent([SkillRequiredTool].self, forKey: .requiredTools) ?? [] + } + + var semanticVersion: String? { + version?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + var homepageURL: URL? { + sanitizedExternalURL(homepage) + } + + var requiredBinsLabel: String? { + let bins = requiredBins + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return bins.isEmpty ? nil : bins.joined(separator: ", ") + } + + var hasMissingRequiredTools: Bool { + requiredTools.contains { !$0.available } + } + + func requiredToolsLabel(availableLabel: String, missingLabel: String) -> String? { + if !requiredTools.isEmpty { + let labels = requiredTools + .filter { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .map { tool in + "\(tool.name.trimmingCharacters(in: .whitespacesAndNewlines)) \(tool.available ? availableLabel : missingLabel)" + } + return labels.isEmpty ? nil : labels.joined(separator: " · ") + } + return requiredBinsLabel + } +} + +struct SkillRequiredTool: Codable, Equatable { + let name: String + let available: Bool } struct LocalAgent: Identifiable, Codable, Equatable { @@ -614,6 +717,7 @@ struct CapabilityPackage: Identifiable, Codable, Equatable { var sourceURL: URL? { return explicitOrRepositoryURL( readmeUrl: source.readmeUrl ?? source.location, + fallbackExplicitURL: nil, repoOwner: source.repoOwner, repoName: source.repoName ) @@ -647,6 +751,15 @@ struct CapabilityPackage: Identifiable, Codable, Equatable { ].filter { $0.total > 0 } } + var componentCompositionCounts: [PackageComponentCompositionCount] { + [ + PackageComponentCompositionCount(kind: "cli", count: components.cli.count), + PackageComponentCompositionCount(kind: "mcp", count: components.mcp.count), + PackageComponentCompositionCount(kind: "skill", count: components.skills.count), + PackageComponentCompositionCount(kind: "agent", count: components.agents.count) + ].filter { $0.count > 0 } + } + var lastLifecycleTimestamp: Int? { [lifecycle?.installedAt, lifecycle?.updatedAt] .compactMap { value -> Int? in @@ -663,6 +776,22 @@ struct CapabilityPackage: Identifiable, Codable, Equatable { .trimmingCharacters(in: .whitespacesAndNewlines) .nilIfEmpty } + + func installedSizeBytes(in skills: [Skill]) -> UInt64? { + let total = matchingInstalledSkills(in: skills) + .compactMap(\.sizeBytes) + .reduce(UInt64(0), +) + return total > 0 ? total : nil + } + + func componentVersionSummaries(in skills: [Skill]) -> [PackageComponentVersionSummary] { + components.all.map { component in + PackageComponentVersionSummary( + component: component, + versionLabel: componentVersionLabel(for: component, in: skills) + ) + } + } } struct PackageComponentGroupSummary: Identifiable, Equatable { @@ -687,6 +816,85 @@ struct PackageComponentGroupSummary: Identifiable, Equatable { } } +struct PackageAppCoverageBreakdown: Equatable { + var total: Int + var enabled: Int = 0 + var stubbed: Int = 0 + var off: Int = 0 + var unsupported: Int = 0 + + var label: String { "\(enabled)/\(total)" } + + var percent: Int { + guard total > 0 else { return 0 } + return Int((Double(enabled) / Double(total) * 100).rounded()) + } + + mutating func add(_ state: PackageComponentAppState) { + switch state { + case .active: + enabled += 1 + case .stub: + stubbed += 1 + case .off: + off += 1 + case .unsupported: + unsupported += 1 + } + } +} + +struct PackageComponentCompositionCount: Identifiable, Equatable { + let kind: String + let count: Int + + var id: String { kind } +} + +enum PackageComponentCompositionFormatter { + static func composition(for package: CapabilityPackage, localization: PopskillLocalization) -> String { + let parts = package.componentCompositionCounts.map { count in + localization.string(titleKey(kind: count.kind, count: count.count), count.count) + } + return parts.isEmpty ? localization.string("package.componentComposition.empty") : parts.joined(separator: " + ") + } + + static func label(for package: CapabilityPackage, localization: PopskillLocalization) -> String { + composition(for: package, localization: localization) + } + + static func summary(for package: CapabilityPackage, localization: PopskillLocalization) -> String { + let composition = composition(for: package, localization: localization) + let summary = package.summary.trimmingCharacters(in: .whitespacesAndNewlines) + return summary.isEmpty ? composition : "\(summary) · \(composition)" + } + + private static func titleKey(kind: String, count: Int) -> String { + switch kind.lowercased() { + case "cli": return count == 1 ? "package.componentComposition.cli.one" : "package.componentComposition.cli.other" + case "mcp": return count == 1 ? "package.componentComposition.mcp.one" : "package.componentComposition.mcp.other" + case "agent": return count == 1 ? "package.componentComposition.agent.one" : "package.componentComposition.agent.other" + default: return count == 1 ? "package.componentComposition.skill.one" : "package.componentComposition.skill.other" + } + } +} + +struct PackageComponentVersionSummary: Identifiable, Equatable { + let id: String + let name: String + let kind: String + let status: String + let versionLabel: String? + + init(component: PackageComponent, versionLabel: String?) { + id = component.displayKey + name = component.name + kind = component.kind + status = component.status + self.versionLabel = versionLabel + } +} + struct PackageSource: Codable, Equatable { let kind: String let location: String @@ -747,6 +955,23 @@ struct PackageComponent: Codable, Equatable { return false } } + + var isStubbed: Bool { + status.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .contains("stub") + } +} + +enum PackageComponentAppState: Equatable { + case active + case stub + case off + case unsupported + + var isEnabled: Bool { + self == .active + } } struct PackageConfigField: Identifiable, Codable, Equatable { @@ -797,15 +1022,25 @@ struct CatalogSkill: Identifiable, Codable, Equatable { } var sourceURL: URL? { - explicitOrRepositoryURL(readmeUrl: readmeUrl, repoOwner: repoOwner, repoName: repoName) + explicitOrRepositoryURL( + readmeUrl: readmeUrl, + fallbackExplicitURL: nil, + repoOwner: repoOwner, + repoName: repoName + ) } } -private func explicitOrRepositoryURL(readmeUrl: String?, repoOwner: String?, repoName: String?) -> URL? { - if let readmeUrl, - let url = URL(string: readmeUrl), - let scheme = url.scheme?.lowercased(), - ["http", "https"].contains(scheme) { +private func explicitOrRepositoryURL( + readmeUrl: String?, + fallbackExplicitURL: String?, + repoOwner: String?, + repoName: String? +) -> URL? { + if let url = sanitizedExternalURL(readmeUrl) { + return url + } + if let url = sanitizedExternalURL(fallbackExplicitURL) { return url } @@ -820,6 +1055,16 @@ private func explicitOrRepositoryURL(readmeUrl: String?, repoOwner: String?, rep return components.url } +private func sanitizedExternalURL(_ rawValue: String?) -> URL? { + guard let rawValue, + let url = URL(string: rawValue.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme) else { + return nil + } + return url +} + private func githubRepositoryURL(from ownerRepo: String) -> URL? { let parts = ownerRepo.split(separator: "/", omittingEmptySubsequences: true) guard parts.count == 2 else { @@ -1059,6 +1304,18 @@ struct AppLinkStatus: Codable, Equatable, Hashable { let status: String } +extension SkillDeployment { + var hasBrokenLink: Bool { + appLinks.values.contains { $0.isBroken } + } +} + +extension AppLinkStatus { + var isBroken: Bool { + status.caseInsensitiveCompare("broken") == .orderedSame + } +} + /// Top-level envelope returned by `skill-cli link-health --json`. struct LinkHealthReport: Codable, Equatable { let summary: LinkHealthSummary @@ -1077,6 +1334,30 @@ struct LinkHealthRow: Codable, Equatable { let deployment: SkillDeployment? } +struct PackageLinkHealthSnapshot: Equatable { + let rows: [LinkHealthRow] + + var okCount: Int { + statusCount("ok") + } + + var brokenCount: Int { + statusCount("broken") + } + + var inactiveCount: Int { + statusCount("inactive") + } + + private func statusCount(_ status: String) -> Int { + rows.reduce(0) { count, row in + count + (row.deployment?.appLinks.values.filter { + $0.status.caseInsensitiveCompare(status) == .orderedSame + }.count ?? 0) + } + } +} + /// Envelope returned by `skill-cli onboard-scan --json`. Powers onboarding /// wizard step 3. struct OnboardScanReport: Codable, Equatable { @@ -1122,6 +1403,115 @@ struct SyncResult: Codable, Equatable { let stderr: String? let message: String? let implemented: Bool? + let localPath: String? + let remotePath: String? + let localCount: Int? + let remoteCount: Int? + + init( + provider: String, + action: String, + ok: Bool? = nil, + exitCode: Int? = nil, + stdout: String? = nil, + stderr: String? = nil, + message: String? = nil, + implemented: Bool? = nil, + localPath: String? = nil, + remotePath: String? = nil, + localCount: Int? = nil, + remoteCount: Int? = nil + ) { + self.provider = provider + self.action = action + self.ok = ok + self.exitCode = exitCode + self.stdout = stdout + self.stderr = stderr + self.message = message + self.implemented = implemented + self.localPath = localPath + self.remotePath = remotePath + self.localCount = localCount + self.remoteCount = remoteCount + } +} + +struct SyncResultSummary: Equatable { + enum State: Equatable { + case success + case failure + case unavailable + case unknown + } + + enum Detail: Equatable { + case exitCode(Int) + case localEndpoint(path: String?, count: Int?) + case remoteEndpoint(path: String?, count: Int?) + } + + let state: State + let message: String + let details: [Detail] +} + +extension SyncResult { + func summary(successMessage: String, emptyMessage: String) -> SyncResultSummary { + let state = summaryState + let message = preferredMessage(for: state) ?? fallbackMessage(for: state, successMessage: successMessage, emptyMessage: emptyMessage) + var details: [SyncResultSummary.Detail] = [] + + if let exitCode, state != .success || exitCode != 0 { + details.append(.exitCode(exitCode)) + } + if localPath != nil || localCount != nil { + details.append(.localEndpoint(path: localPath, count: localCount)) + } + if remotePath != nil || remoteCount != nil { + details.append(.remoteEndpoint(path: remotePath, count: remoteCount)) + } + + return SyncResultSummary(state: state, message: message, details: details) + } + + private var summaryState: SyncResultSummary.State { + if implemented == false { + return .unavailable + } + if ok == true { + return .success + } + if ok == false { + return .failure + } + return .unknown + } + + private func preferredMessage(for state: SyncResultSummary.State) -> String? { + let candidates: [String?] + switch state { + case .failure, .unavailable: + candidates = [message, stderr, stdout] + case .success, .unknown: + candidates = [message, stdout, stderr] + } + return candidates.compactMap(Self.cleanedOutput).first + } + + private func fallbackMessage(for state: SyncResultSummary.State, successMessage: String, emptyMessage: String) -> String { + state == .success ? successMessage : emptyMessage + } + + private static func cleanedOutput(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.count <= 280 { + return trimmed + } + return String(trimmed.prefix(280)).trimmingCharacters(in: .whitespacesAndNewlines) + "..." + } } struct UnmanagedSkill: Identifiable, Codable, Equatable { @@ -1160,6 +1550,59 @@ extension SkillUpdateInfo { } extension PackageComponent { + func matchesSkill(_ skill: Skill) -> Bool { + guard kind.caseInsensitiveCompare("skill") == .orderedSame else { + return false + } + + let candidates = [ + id, + name, + location ?? "", + location?.split(separator: "/").last.map(String.init) ?? "" + ] + .map(Self.normalizedIdentifier) + .filter { !$0.isEmpty } + + let skillCandidates = [ + skill.id, + skill.name, + skill.directory, + skill.id.split(separator: ":", maxSplits: 1).last.map(String.init) ?? skill.id + ] + .map(Self.normalizedIdentifier) + .filter { !$0.isEmpty } + + return !Set(candidates).isDisjoint(with: Set(skillCandidates)) + } + + func matchesAttributionSkill(_ identifier: String) -> Bool { + guard kind.caseInsensitiveCompare("skill") == .orderedSame else { + return false + } + + let normalizedIdentifier = Self.normalizedIdentifier(identifier) + guard !normalizedIdentifier.isEmpty else { + return false + } + + let identifierSuffix = normalizedIdentifier + .split(separator: ":", maxSplits: 1) + .last + .map(String.init) ?? normalizedIdentifier + + let candidates = [ + id, + name, + location ?? "", + location?.split(separator: "/").last.map(String.init) ?? "" + ] + .map(Self.normalizedIdentifier) + .filter { !$0.isEmpty } + + return candidates.contains(normalizedIdentifier) || candidates.contains(identifierSuffix) + } + func matchesSkillUpdate(_ update: SkillUpdateInfo) -> Bool { guard kind.caseInsensitiveCompare("skill") == .orderedSame else { return false @@ -1179,12 +1622,199 @@ extension PackageComponent { return name.caseInsensitiveCompare(update.name) == .orderedSame } + + private static func normalizedIdentifier(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } } extension CapabilityPackage { + func containsSkill(_ skill: Skill) -> Bool { + components.skills.contains { $0.matchesSkill(skill) } + } + + func companionInstalledSkills(for skill: Skill, in skills: [Skill]) -> [Skill] { + matchingInstalledSkills(in: skills) + .filter { $0.id != skill.id } + } + + func installedSkillsRequiringEnablement(for app: TargetApp, in skills: [Skill]) -> [Skill] { + matchingInstalledSkills(in: skills) + .filter { !$0.apps.isEnabled(app) } + } + + func matchingInstalledSkills(in skills: [Skill]) -> [Skill] { + var seen: Set = [] + var matches: [Skill] = [] + + for component in components.skills { + guard let skill = matchingInstalledSkill(for: component, in: skills), + !seen.contains(skill.id) else { + continue + } + seen.insert(skill.id) + matches.append(skill) + } + + return matches + } + + func hasBrokenLinks(in skills: [Skill]) -> Bool { + matchingInstalledSkills(in: skills).contains { $0.hasBrokenLink } + } + + func linkHealthSnapshot(using report: LinkHealthReport?, skills: [Skill]) -> PackageLinkHealthSnapshot? { + guard let report else { + return nil + } + + let matchedSkills = matchingInstalledSkills(in: skills) + let matchedSkillIdentifiers = Set(matchedSkills.flatMap { skill in + [ + skill.id, + skill.name, + skill.directory, + skill.id.split(separator: ":", maxSplits: 1).last.map(String.init) ?? skill.id + ].map(Self.normalizedIdentifier).filter { !$0.isEmpty } + }) + + let rows = report.rows.filter { row in + let rowCandidates = [ + row.skillId, + row.skillName, + row.skillId.split(separator: ":", maxSplits: 1).last.map(String.init) ?? row.skillId + ].map(Self.normalizedIdentifier).filter { !$0.isEmpty } + + if !Set(rowCandidates).isDisjoint(with: matchedSkillIdentifiers) { + return true + } + + return components.skills.contains { component in + component.matchesAttributionSkill(row.skillId) || component.matchesAttributionSkill(row.skillName) + } + } + + return PackageLinkHealthSnapshot(rows: rows) + } + + func componentVersionLabel(for component: PackageComponent, in skills: [Skill]) -> String? { + guard let skill = matchingInstalledSkill(for: component, in: skills) else { + return nil + } + return MatrixVersionFormatter.value( + manifestVersion: skill.manifest?.semanticVersion, + contentHash: skill.contentHash, + updatedAt: skill.updatedAt + ) + } + + func matchingInstalledSkill(for component: PackageComponent, in skills: [Skill]) -> Skill? { + skills.first { component.matchesSkill($0) } + } + + func appCoverage(using skills: [Skill]) -> [TargetApp: CapabilityAppCoverage] { + Dictionary(uniqueKeysWithValues: TargetApp.supported.map { app in + let breakdown = appCoverageBreakdown(for: app, skills: skills) + return (app, CapabilityAppCoverage(enabled: breakdown.enabled, total: breakdown.total)) + }) + } + + func appCoverageBreakdown(for app: TargetApp, skills: [Skill]) -> PackageAppCoverageBreakdown { + var breakdown = PackageAppCoverageBreakdown(total: components.all.count) + for component in components.all { + let state = component.appState( + for: app, + matching: matchingInstalledSkill(for: component, in: skills) + ) + breakdown.add(state) + } + return breakdown + } + func matchingSkillComponent(for update: SkillUpdateInfo) -> PackageComponent? { components.skills.first { $0.matchesSkillUpdate(update) } } + + func usageSnapshot(using summary: UsageSummary?, skills: [Skill]) -> PackageUsageSnapshot? { + guard let summary else { + return nil + } + + let matchedSkills = matchingInstalledSkills(in: skills) + var snapshot = PackageUsageSnapshot() + + for stat in summary.thirtyDaySkillStats { + guard let component = usageComponent(for: stat, matchedSkills: matchedSkills) else { + continue + } + snapshot.add(stat, component: component) + } + + return snapshot + } + + private func usageComponent(for stat: SkillUsageStat, matchedSkills: [Skill]) -> PackageComponent? { + if let matchedSkill = matchedSkills.first(where: { $0.matchesAttributionSkill(stat.skillID) }), + let component = components.skills.first(where: { $0.matchesSkill(matchedSkill) }) { + return component + } + + return components.skills.first { component in + component.matchesAttributionSkill(stat.skillID) + } + } + + private static func normalizedIdentifier(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +private extension PackageComponent { + func isEnabled(for app: TargetApp, matching skill: Skill?) -> Bool { + appState(for: app, matching: skill).isEnabled + } +} + +extension PackageComponent { + func appState(for app: TargetApp, matching skill: Skill?) -> PackageComponentAppState { + switch kind.lowercased() { + case "skill": + if let skill { + if skill.apps.isEnabled(app) { + return .active + } + return isStubbed ? .stub : .off + } + guard app == .claude || app == .codex else { + return .unsupported + } + if installed { + return .active + } + return isStubbed ? .stub : .off + case "agent": + guard app == .claude || app == .codex else { + return .unsupported + } + guard app == .claude else { + return .off + } + if installed { + return .active + } + return isStubbed ? .stub : .off + case "cli", "mcp": + guard app == .claude || app == .codex else { + return .unsupported + } + if installed { + return .active + } + return isStubbed ? .stub : .off + default: + return .unsupported + } + } } struct CLIResponse: Decodable { diff --git a/swift-app/Sources/Popskill/Models/SkillSearchScorer.swift b/swift-app/Sources/Popskill/Models/SkillSearchScorer.swift index ebd8fcf..02a15ce 100644 --- a/swift-app/Sources/Popskill/Models/SkillSearchScorer.swift +++ b/swift-app/Sources/Popskill/Models/SkillSearchScorer.swift @@ -30,38 +30,40 @@ enum SkillSearchScorer { /// sourceLabel contains 10 /// directory contains 5 static func score(skill: Skill, query: String) -> SkillSearchHit? { - let q = query.lowercased() + let q = SearchTextNormalizer.key(query) guard !q.isEmpty else { return nil } - let name = skill.name.lowercased() - let summary = (skill.capabilitySummary ?? "").lowercased() - let description = skill.description.lowercased() + let name = SearchTextNormalizer.key(skill.name) + let summary = SearchTextNormalizer.key(skill.capabilitySummary ?? "") + let description = SearchTextNormalizer.key(skill.description) let triggers = skill.triggerScenarios ?? [] - let source = skill.sourceLabel.lowercased() - let directory = skill.directory.lowercased() + let source = SearchTextNormalizer.key(skill.sourceLabel) + let directory = SearchTextNormalizer.key(skill.directory) var score = 0 var matchedOnName = false var matchedTriggers: [String] = [] var seenTriggers: Set = [] - if name == q { + if name.equals(q) { score += 1000 matchedOnName = true } else if name.hasPrefix(q) { score += 500 matchedOnName = true - } else if name.contains(q) { + } else if matches(skill.name, key: name, query: q, rawQuery: query) { score += 200 matchedOnName = true } for trigger in triggers { - let lowerTrigger = trigger.lowercased() - guard lowerTrigger.contains(q), !seenTriggers.contains(lowerTrigger) else { + let triggerKey = SearchTextNormalizer.key(trigger) + guard matches(trigger, key: triggerKey, query: q, rawQuery: query), + !seenTriggers.contains(triggerKey.compact) + else { continue } - seenTriggers.insert(lowerTrigger) + seenTriggers.insert(triggerKey.compact) score += 100 matchedTriggers.append(trigger) } @@ -95,36 +97,38 @@ enum SkillSearchScorer { /// uses `categoryLabel` and `fileName` as auxiliary fields instead of /// `sourceLabel`/`directory`. Returns nil when no field matches. static func score(agent: LocalAgent, query: String) -> SkillSearchHit? { - let q = query.lowercased() + let q = SearchTextNormalizer.key(query) guard !q.isEmpty else { return nil } - let name = agent.name.lowercased() - let summary = (agent.capabilitySummary ?? "").lowercased() - let description = agent.description.lowercased() + let name = SearchTextNormalizer.key(agent.name) + let summary = SearchTextNormalizer.key(agent.capabilitySummary ?? "") + let description = SearchTextNormalizer.key(agent.description) let triggers = agent.triggerScenarios ?? [] - let category = agent.categoryLabel.lowercased() - let fileName = agent.fileName.lowercased() + let category = SearchTextNormalizer.key(agent.categoryLabel) + let fileName = SearchTextNormalizer.key(agent.fileName) var score = 0 var matchedOnName = false var matchedTriggers: [String] = [] var seenTriggers: Set = [] - if name == q { + if name.equals(q) { score += 1000 matchedOnName = true } else if name.hasPrefix(q) { score += 500 matchedOnName = true - } else if name.contains(q) { + } else if matches(agent.name, key: name, query: q, rawQuery: query) { score += 200 matchedOnName = true } for trigger in triggers { - let lower = trigger.lowercased() - guard lower.contains(q), !seenTriggers.contains(lower) else { continue } - seenTriggers.insert(lower) + let triggerKey = SearchTextNormalizer.key(trigger) + guard matches(trigger, key: triggerKey, query: q, rawQuery: query), + !seenTriggers.contains(triggerKey.compact) + else { continue } + seenTriggers.insert(triggerKey.compact) score += 100 matchedTriggers.append(trigger) } @@ -150,4 +154,43 @@ enum SkillSearchScorer { matchedOnName: matchedOnName ) } + + private static func matches( + _ text: String, + key: SearchTextKey, + query: SearchTextKey, + rawQuery: String + ) -> Bool { + guard !query.isEmpty else { return false } + if key.contains(query) { + return true + } + let normalizedQuery = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedText = text.lowercased() + guard containsCJKScalar(normalizedQuery), containsCJKScalar(normalizedText) else { + return false + } + return normalizedText.containsCharactersInOrder(normalizedQuery) + } + + private static func containsCJKScalar(_ value: String) -> Bool { + value.unicodeScalars.contains { scalar in + (0x4E00...0x9FFF).contains(scalar.value) + || (0x3400...0x4DBF).contains(scalar.value) + || (0xF900...0xFAFF).contains(scalar.value) + } + } +} + +private extension String { + func containsCharactersInOrder(_ query: String) -> Bool { + var searchStart = startIndex + for character in query { + guard let match = self[searchStart...].firstIndex(of: character) else { + return false + } + searchStart = index(after: match) + } + return true + } } diff --git a/swift-app/Sources/Popskill/Models/UsageModels.swift b/swift-app/Sources/Popskill/Models/UsageModels.swift index c650a9b..1c9dd11 100644 --- a/swift-app/Sources/Popskill/Models/UsageModels.swift +++ b/swift-app/Sources/Popskill/Models/UsageModels.swift @@ -12,6 +12,7 @@ struct UsageSummary: Equatable { var modelStats: [ModelUsageStat] = [] var skillStats: [SkillUsageStat] = [] var recentSessions: [SessionUsageStat] = [] + var recent30Days: UsageWindowSummary? var totalTokens: Int64 { inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens @@ -20,6 +21,94 @@ struct UsageSummary: Equatable { var unattributedUsageEvents: Int { max(0, usageEvents - attributedSkillUsageEvents) } + + var thirtyDayTotalTokens: Int64 { + recent30Days?.totalTokens ?? totalTokens + } + + var thirtyDaySkillStats: [SkillUsageStat] { + recent30Days?.skillStats ?? skillStats + } +} + +struct UsageWindowSummary: Equatable { + let days: Int + let startedAt: Date + let endedAt: Date + var usageEvents = 0 + var inputTokens: Int64 = 0 + var outputTokens: Int64 = 0 + var cacheCreationTokens: Int64 = 0 + var cacheReadTokens: Int64 = 0 + var attributedSkillUsageEvents = 0 + var modelStats: [ModelUsageStat] = [] + var skillStats: [SkillUsageStat] = [] + var dailyStats: [UsageBucketStat] = [] + + var totalTokens: Int64 { + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + } + + var unattributedUsageEvents: Int { + max(0, usageEvents - attributedSkillUsageEvents) + } + + mutating func addUsage( + inputTokens: Int64, + outputTokens: Int64, + cacheCreationTokens: Int64, + cacheReadTokens: Int64, + dayStart: Date + ) { + usageEvents += 1 + self.inputTokens += inputTokens + self.outputTokens += outputTokens + self.cacheCreationTokens += cacheCreationTokens + self.cacheReadTokens += cacheReadTokens + Self.mergeDailyStat( + UsageBucketStat( + dayStart: dayStart, + usageEvents: 1, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationTokens: cacheCreationTokens, + cacheReadTokens: cacheReadTokens + ), + into: &dailyStats + ) + } + + static func mergeDailyStat(_ stat: UsageBucketStat, into stats: inout [UsageBucketStat]) { + if let index = stats.firstIndex(where: { $0.dayStart == stat.dayStart }) { + stats[index].add(stat) + } else { + stats.append(stat) + } + stats.sort { $0.dayStart < $1.dayStart } + } +} + +struct UsageBucketStat: Identifiable, Equatable { + var id: Date { dayStart } + + let dayStart: Date + var usageEvents: Int + var inputTokens: Int64 + var outputTokens: Int64 + var cacheCreationTokens: Int64 + var cacheReadTokens: Int64 + + var totalTokens: Int64 { + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + } + + mutating func add(_ stat: UsageBucketStat) { + usageEvents += stat.usageEvents + inputTokens += stat.inputTokens + outputTokens += stat.outputTokens + cacheCreationTokens += stat.cacheCreationTokens + cacheReadTokens += stat.cacheReadTokens + } } struct ModelUsageStat: Identifiable, Equatable { @@ -92,6 +181,7 @@ struct SkillUsageStat: Identifiable, Equatable { var cacheCreationTokens: Int64 var cacheReadTokens: Int64 var lastUsedAt: Date? + var dailyStats: [UsageBucketStat] = [] var totalTokens: Int64 { inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens @@ -102,7 +192,8 @@ struct SkillUsageStat: Identifiable, Equatable { outputTokens: Int64, cacheCreationTokens: Int64, cacheReadTokens: Int64, - timestamp: Date? + timestamp: Date?, + dayStart: Date? = nil ) { usageEvents += 1 self.inputTokens += inputTokens @@ -110,6 +201,20 @@ struct SkillUsageStat: Identifiable, Equatable { self.cacheCreationTokens += cacheCreationTokens self.cacheReadTokens += cacheReadTokens + if let dayStart { + UsageWindowSummary.mergeDailyStat( + UsageBucketStat( + dayStart: dayStart, + usageEvents: 1, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationTokens: cacheCreationTokens, + cacheReadTokens: cacheReadTokens + ), + into: &dailyStats + ) + } + guard let timestamp else { return } @@ -118,3 +223,230 @@ struct SkillUsageStat: Identifiable, Equatable { } } } + +struct SkillUsageSnapshot: Equatable { + var usageEvents = 0 + var inputTokens: Int64 = 0 + var outputTokens: Int64 = 0 + var cacheCreationTokens: Int64 = 0 + var cacheReadTokens: Int64 = 0 + var lastUsedAt: Date? + var dailyStats: [UsageBucketStat] = [] + + var totalTokens: Int64 { + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + } + + var hasUsage: Bool { + usageEvents > 0 || totalTokens > 0 + } + + mutating func add(_ stat: SkillUsageStat) { + usageEvents += stat.usageEvents + inputTokens += stat.inputTokens + outputTokens += stat.outputTokens + cacheCreationTokens += stat.cacheCreationTokens + cacheReadTokens += stat.cacheReadTokens + mergeDailyStats(stat.dailyStats) + + guard let date = stat.lastUsedAt else { + return + } + if lastUsedAt.map({ date > $0 }) ?? true { + lastUsedAt = date + } + } + + mutating func mergeDailyStats(_ stats: [UsageBucketStat]) { + for stat in stats { + UsageWindowSummary.mergeDailyStat(stat, into: &dailyStats) + } + } +} + +struct MatrixUsageIndex: Equatable { + let hasSummary: Bool + private var skillSnapshotsByID: [String: SkillUsageSnapshot] = [:] + private var packageSnapshotsByID: [String: PackageUsageSnapshot] = [:] + private var packageComponentStatsByID: [String: [String: PackageComponentUsageStat]] = [:] + + init(summary: UsageSummary?, skills: [Skill], packages: [CapabilityPackage]) { + guard let summary else { + hasSummary = false + return + } + + hasSummary = true + + for skill in skills { + skillSnapshotsByID[skill.id] = skill.usageSnapshot(using: summary) + } + + for package in packages { + guard let snapshot = package.usageSnapshot(using: summary, skills: skills) else { + continue + } + packageSnapshotsByID[package.id] = snapshot + packageComponentStatsByID[package.id] = Dictionary( + uniqueKeysWithValues: snapshot.componentStats.map { ($0.componentID, $0) } + ) + } + } + + func skillSnapshot(for skillID: String?) -> SkillUsageSnapshot? { + guard hasSummary, let skillID else { + return nil + } + return skillSnapshotsByID[skillID] ?? SkillUsageSnapshot() + } + + func packageSnapshot(for packageID: String?) -> PackageUsageSnapshot? { + guard hasSummary, let packageID else { + return nil + } + return packageSnapshotsByID[packageID] ?? PackageUsageSnapshot() + } + + func packageComponentStat(packageID: String?, componentID: String) -> PackageComponentUsageStat? { + guard hasSummary, let packageID else { + return nil + } + return packageComponentStatsByID[packageID]?[componentID] + } +} + +enum UsageDisplayFormatter { + static func compactTokens(_ value: Int64) -> String { + compact(Int(value)) + } + + static func compactCount(_ value: Int) -> String { + compact(value) + } + + private static func compact(_ value: Int) -> String { + if value < 1_000 { + return integerFormatter.string(from: NSNumber(value: value)) ?? "0" + } + if value < 1_000_000 { + return String(format: "%.1fK", Double(value) / 1_000.0) + } + if value < 1_000_000_000 { + return String(format: "%.1fM", Double(value) / 1_000_000.0) + } + return String(format: "%.2fB", Double(value) / 1_000_000_000.0) + } + + private static let integerFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + return formatter + }() +} + +struct PackageUsageSnapshot: Equatable { + var matchedSkillCount = 0 + var usageEvents = 0 + var inputTokens: Int64 = 0 + var outputTokens: Int64 = 0 + var cacheCreationTokens: Int64 = 0 + var cacheReadTokens: Int64 = 0 + var lastUsedAt: Date? + var componentStats: [PackageComponentUsageStat] = [] + var dailyStats: [UsageBucketStat] = [] + + var totalTokens: Int64 { + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + } + + var hasUsage: Bool { + usageEvents > 0 || totalTokens > 0 + } + + mutating func add(_ stat: SkillUsageStat, component: PackageComponent) { + matchedSkillCount += 1 + usageEvents += stat.usageEvents + inputTokens += stat.inputTokens + outputTokens += stat.outputTokens + cacheCreationTokens += stat.cacheCreationTokens + cacheReadTokens += stat.cacheReadTokens + for bucket in stat.dailyStats { + UsageWindowSummary.mergeDailyStat(bucket, into: &dailyStats) + } + upsertComponentStat(stat, component: component) + + guard let date = stat.lastUsedAt else { + return + } + if lastUsedAt.map({ date > $0 }) ?? true { + lastUsedAt = date + } + } + + private mutating func upsertComponentStat(_ stat: SkillUsageStat, component: PackageComponent) { + if let index = componentStats.firstIndex(where: { $0.componentID == component.id }) { + componentStats[index].add(stat) + } else { + componentStats.append(PackageComponentUsageStat(component: component, stat: stat)) + } + + componentStats.sort { lhs, rhs in + if lhs.usageEvents != rhs.usageEvents { return lhs.usageEvents > rhs.usageEvents } + if lhs.totalTokens != rhs.totalTokens { return lhs.totalTokens > rhs.totalTokens } + return lhs.componentName.localizedCaseInsensitiveCompare(rhs.componentName) == .orderedAscending + } + } +} + +struct PackageComponentUsageStat: Identifiable, Equatable { + var id: String { componentID } + + let componentID: String + let componentName: String + let componentKind: String + let installed: Bool + var usageEvents: Int + var inputTokens: Int64 + var outputTokens: Int64 + var cacheCreationTokens: Int64 + var cacheReadTokens: Int64 + var lastUsedAt: Date? + var dailyStats: [UsageBucketStat] + + var totalTokens: Int64 { + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + } + + init(component: PackageComponent, stat: SkillUsageStat) { + componentID = component.id + componentName = component.name + componentKind = component.kind + installed = component.installed + usageEvents = stat.usageEvents + inputTokens = stat.inputTokens + outputTokens = stat.outputTokens + cacheCreationTokens = stat.cacheCreationTokens + cacheReadTokens = stat.cacheReadTokens + lastUsedAt = stat.lastUsedAt + dailyStats = stat.dailyStats + } + + mutating func add(_ stat: SkillUsageStat) { + usageEvents += stat.usageEvents + inputTokens += stat.inputTokens + outputTokens += stat.outputTokens + cacheCreationTokens += stat.cacheCreationTokens + cacheReadTokens += stat.cacheReadTokens + for bucket in stat.dailyStats { + UsageWindowSummary.mergeDailyStat(bucket, into: &dailyStats) + } + + guard let date = stat.lastUsedAt else { + return + } + if lastUsedAt.map({ date > $0 }) ?? true { + lastUsedAt = date + } + } +} diff --git a/swift-app/Sources/Popskill/Resources/en.lproj/Localizable.strings b/swift-app/Sources/Popskill/Resources/en.lproj/Localizable.strings index 0f81732..e5fa881 100644 --- a/swift-app/Sources/Popskill/Resources/en.lproj/Localizable.strings +++ b/swift-app/Sources/Popskill/Resources/en.lproj/Localizable.strings @@ -157,6 +157,15 @@ "Installed Components" = "Installed Components"; "Component Tree" = "Component Tree"; "package.componentSummary" = "%d components · %d installed · %d required"; +"package.componentComposition.cli.one" = "%d CLI"; +"package.componentComposition.cli.other" = "%d CLI"; +"package.componentComposition.mcp.one" = "%d MCP"; +"package.componentComposition.mcp.other" = "%d MCP"; +"package.componentComposition.skill.one" = "%d Skill"; +"package.componentComposition.skill.other" = "%d Skills"; +"package.componentComposition.agent.one" = "%d Agent"; +"package.componentComposition.agent.other" = "%d Agents"; +"package.componentComposition.empty" = "No components"; "Directory" = "Directory"; "Identifier" = "Identifier"; "Hash" = "Hash"; @@ -304,20 +313,46 @@ "sidebar.section.control" = "CONTROL"; "sidebar.section.sources" = "SOURCES"; "sidebar.section.maintenance" = "MAINTENANCE"; +"sidebar.matrixFilters" = "Filters"; +"sidebar.matrixTypes" = "Types"; +"sidebar.sync" = "Sync"; +"sidebar.sync.settings" = "Settings"; +"sidebar.sync.openSettings" = "Open sync settings"; +"sidebar.sync.never" = "Not synced yet"; +"sidebar.sync.last" = "Last %@"; // S3 新增 — Matrix + Inspector -"matrix.subtitle" = "%d capabilities · %d active toggles"; +"matrix.subtitle" = "%d bundles · %d capabilities · %d active toggles"; "matrix.search.placeholder" = "Filter by name, trigger, or directory"; +"matrix.metric.capabilities" = "Capabilities"; +"matrix.metric.claudeActive" = "Claude active"; +"matrix.metric.codexActive" = "Codex active"; +"matrix.metric.stubs" = "Stubs"; +"matrix.metric.brokenLinks" = "Broken links"; +"matrix.metric.tokenUsage" = "30d tokens"; +"matrix.sort.typeDescending" = "Sort: type"; +"matrix.sort.typeAscending" = "Sort: type reverse"; +"matrix.sort.nameAscending" = "Sort: name A-Z"; +"matrix.sort.nameDescending" = "Sort: name Z-A"; +"matrix.sort.callsDescending" = "Sort: calls"; +"matrix.sort.tokensDescending" = "Sort: tokens"; +"matrix.sort.recentDescending" = "Sort: recent"; +"matrix.sort.help" = "Choose matrix row sort"; "matrix.col.capability" = "Capability"; "matrix.col.source" = "Source"; +"matrix.col.version" = "Version"; +"matrix.col.tokens" = "Tokens"; +"matrix.col.calls" = "Calls"; "matrix.filter.all" = "All"; "matrix.filter.updates" = "Updates"; +"matrix.filter.brokenLinks" = "Broken links"; "matrix.filter.claudeOnly" = "Claude only"; "matrix.filter.codexOnly" = "Codex only"; "matrix.filter.inactive" = "Inactive"; "matrix.type.all" = "All types"; +"matrix.type.bundle" = "Bundle"; "matrix.type.skill" = "Skill"; "matrix.type.agent" = "Agent"; "matrix.type.cli" = "CLI"; @@ -329,6 +364,8 @@ "matrix.group.ungrouped" = "Other"; +"matrix.row.brokenLinkBadge" = "Broken"; +"matrix.row.brokenLinkHelp" = "One or more app links are broken"; "matrix.row.updateBadge" = "UPDATE"; "matrix.row.noSummary" = "No summary"; "matrix.row.menu.inspect" = "Open Inspector"; @@ -336,6 +373,48 @@ "matrix.row.menu.revealInFinder" = "Reveal in Finder"; "matrix.row.menu.help" = "More actions"; "matrix.group.coverageHelp" = "%@: %d of %d enabled"; +"matrix.package.coverageHelp" = "%@ coverage: %d of %d components"; +"matrix.package.coverage.percent" = "%d%%"; +"matrix.package.coverage.stubbed" = "%d stubbed"; +"matrix.package.coverage.off" = "%d off"; +"matrix.package.coverage.complete" = "Complete"; +"matrix.package.expand" = "Expand components"; +"matrix.package.collapse" = "Collapse components"; +"matrix.package.health.active" = "Complete"; +"matrix.package.health.partial" = "Partial"; +"matrix.package.health.inactive" = "Not installed"; +"matrix.package.health.blocked" = "Missing required"; +"matrix.package.component.required" = "Required"; +"matrix.package.component.optional" = "Optional"; +"matrix.package.component.enabledHelp" = "%@ enabled"; +"matrix.package.component.partialHelp" = "%@ is stubbed or recoverable"; +"matrix.package.component.offHelp" = "%@ not enabled"; +"matrix.package.component.unsupportedHelp" = "%@ is not supported for this component"; +"matrix.package.component.state.active" = "Active"; +"matrix.package.component.state.stub" = "Stub"; +"matrix.package.component.state.off" = "Off"; +"matrix.package.component.state.unsupported" = "N/A"; +"matrix.package.component.calls" = "%@ calls"; +"matrix.package.missingRequired" = "%d required missing"; +"matrix.package.config.secret" = "Keychain secret"; +"matrix.package.usage.tokens" = "30d tokens"; +"matrix.package.usage.calls" = "30d calls"; +"matrix.package.usage.window" = "Last %d days from local transcript attribution."; +"matrix.package.usage.lastUsed" = "Last used %@"; +"matrix.package.usage.trend" = "30d call trend"; +"matrix.package.usage.peak" = "Peak %@ calls on %@"; +"matrix.package.usage.empty" = "Usage was scanned, but the %d local skills matched to this bundle have no attribution records yet."; +"matrix.package.usage.notScanned" = "Transcript usage has not been scanned yet."; +"matrix.package.usage.scanning" = "Scanning local transcripts…"; +"matrix.package.usage.topComponents" = "TOP COMPONENTS"; +"matrix.package.usage.componentCalls" = "%d calls"; +"matrix.skill.usage.empty" = "Usage was scanned, but this skill has no attribution records yet."; +"matrix.skill.usage.notScanned" = "Transcript usage has not been scanned yet."; +"matrix.skill.bundle.open" = "Open bundle"; +"matrix.skill.bundle.companions" = "%d companion skills"; +"matrix.skill.bundle.openCompanion" = "Open %@"; +"matrix.package.paths.empty" = "No local skill files matched this bundle."; +"matrix.package.paths.more" = "%d more matched skills hidden."; "matrix.empty.title" = "No capabilities yet"; "matrix.empty.body" = "Install your first skill from a source, or wait for bootstrap to finish."; @@ -344,12 +423,100 @@ "matrix.inspector.empty.title" = "Select a capability"; "matrix.inspector.empty.body" = "Pick a row to see its description, deployment links, and source."; "matrix.inspector.close" = "Close inspector"; +"matrix.inspector.tab.overview" = "Overview"; +"matrix.inspector.tab.readme" = "README"; +"matrix.inspector.tab.usage" = "Usage"; +"matrix.inspector.tab.version" = "Version"; +"matrix.inspector.tab.paths" = "Paths"; +"matrix.inspector.tab.sync" = "Sync"; +"matrix.inspector.tab.metadata" = "Metadata"; + +"matrix.inspector.header.calls" = "%@ calls"; +"matrix.inspector.header.tokens" = "%@ tokens"; "matrix.inspector.section.summary" = "SUMMARY"; +"matrix.inspector.section.actions" = "ACTIONS"; +"matrix.inspector.section.readme" = "README"; "matrix.inspector.section.triggers" = "TRIGGERS"; "matrix.inspector.section.apps" = "APPS"; +"matrix.inspector.section.coverage" = "COVERAGE"; +"matrix.inspector.section.usage" = "USAGE"; +"matrix.inspector.section.components" = "COMPONENTS"; +"matrix.inspector.section.bundle" = "BUNDLE"; +"matrix.inspector.section.activation" = "ACTIVATION"; +"matrix.inspector.section.machine" = "THIS MACHINE"; +"matrix.inspector.section.source" = "SOURCE & PATHS"; +"matrix.inspector.section.config" = "CONFIG"; +"matrix.inspector.section.paths" = "LOCAL PATHS"; +"matrix.inspector.section.version" = "VERSION"; +"matrix.inspector.section.sync" = "SYNC"; "matrix.inspector.section.deployment" = "DEPLOYMENT"; "matrix.inspector.section.meta" = "METADATA"; +"matrix.package.action.rescanUsage" = "Re-scan usage"; +"matrix.package.action.checkUpdates" = "Check updates"; +"matrix.package.action.openSource" = "Open source"; +"matrix.package.action.revealInFinder" = "Reveal local skill"; +"matrix.package.action.activateClaude" = "Activate all for Claude"; +"matrix.package.action.activateCodex" = "Activate all for Codex"; +"matrix.package.action.activateApp" = "Activate all"; +"matrix.package.activation.empty" = "No matched local skills can be activated from this bundle."; +"matrix.package.activation.complete" = "All matched local skills are active on Claude and Codex."; +"matrix.package.activation.remaining" = "%d Claude gaps · %d Codex gaps"; +"matrix.machine.firstActivated" = "First active"; +"matrix.machine.lastUsed" = "Last used"; +"matrix.machine.tokens" = "30d tokens"; +"matrix.machine.calls" = "30d calls"; +"matrix.machine.topComponent" = "Top component"; +"matrix.machine.size" = "Disk size"; +"matrix.source.repository" = "Repository"; +"matrix.source.directory" = "Directory"; +"matrix.source.localStore" = "Local store"; +"matrix.source.type" = "Source type"; +"matrix.source.author" = "Author"; +"matrix.source.license" = "License"; +"matrix.source.homepage" = "Homepage"; +"matrix.source.requires" = "Requires"; +"matrix.source.readme" = "SKILL.md"; +"matrix.skill.action.openReadme" = "Edit prompt"; +"matrix.skill.action.checkUpdates" = "Check updates"; +"matrix.skill.action.openSource" = "Open source"; +"matrix.skill.action.revealInFinder" = "Reveal in Finder"; +"matrix.skill.paths.readme" = "SKILL.md"; +"matrix.package.version.strategy" = "Strategy"; +"matrix.package.version.branch" = "Branch"; +"matrix.package.version.hash" = "Hash"; +"matrix.package.version.untracked" = "Untracked"; +"matrix.package.version.components" = "Component versions"; +"matrix.skill.manifest.version" = "Version"; +"matrix.skill.manifest.author" = "Author"; +"matrix.skill.manifest.license" = "License"; +"matrix.skill.manifest.requires" = "Requires"; +"matrix.skill.manifest.requires.available" = "ok"; +"matrix.skill.manifest.requires.missing" = "missing"; +"matrix.package.sync.upToDate" = "Up to date"; +"matrix.package.sync.pending" = "%d pending"; +"matrix.package.sync.status" = "Sync status"; +"matrix.package.sync.upstream" = "Upstream updates"; +"matrix.package.sync.source" = "Source"; +"matrix.package.sync.provider" = "Provider"; +"matrix.package.sync.lastSync" = "Last sync"; +"matrix.package.sync.linkHealth" = "Link health"; +"matrix.package.sync.linkHealthNotScanned" = "Not scanned"; +"matrix.package.sync.linkHealthSummary" = "%d OK · %d broken · %d off"; +"matrix.package.sync.linkHealthEmpty" = "No matching deployed skill links were found for this package."; +"matrix.package.sync.linkHealthNotScannedDetail" = "Run link health to inspect package symlinks across Claude and Codex."; +"matrix.package.sync.noDeployment" = "Deployment info unavailable"; +"matrix.package.sync.checking" = "Checking upstream updates..."; +"matrix.package.sync.checked" = "Checked %@"; +"matrix.package.sync.notChecked" = "Not checked yet"; +"matrix.package.sync.clean" = "No package component updates detected."; +"matrix.package.sync.more" = "%d more updates hidden."; +"matrix.readme.loading" = "Loading README preview..."; +"matrix.readme.showing" = "Showing %@"; +"matrix.readme.openFile" = "Open SKILL.md"; +"matrix.readme.reload" = "Reload README"; +"matrix.readme.error" = "README unavailable: %@"; +"matrix.readme.truncated" = "Preview truncated."; "matrix.inspector.deployment.ssot" = "SSOT (real file)"; "matrix.inspector.deployment.strategy" = "Strategy · %@"; @@ -362,6 +529,7 @@ "matrix.inspector.meta.directory" = "Directory"; "matrix.inspector.meta.sourceType" = "Source type"; +"matrix.inspector.meta.packageType" = "Package type"; "matrix.inspector.meta.installedAt" = "Installed"; "matrix.inspector.meta.updatedAt" = "Updated"; "matrix.inspector.meta.size" = "Size"; @@ -373,10 +541,14 @@ "spotlight.clear" = "Clear"; "spotlight.section.capabilities" = "CAPABILITIES"; +"spotlight.section.recent" = "RECENTLY USED"; "spotlight.section.actions" = "ACTIONS"; +"spotlight.bundle.matchedComponents" = "Matched components: %@"; +"spotlight.recentUsage" = "Used %@ · %@ calls"; +"spotlight.recentUsage.unknown" = "recently"; "spotlight.empty.title" = "No matches"; -"spotlight.empty.body" = "Try a name, trigger word, or use ↑↓ to pick a recent capability."; +"spotlight.empty.body" = "Try a name, trigger word, or use ↑↓ to pick a recently used capability."; "spotlight.hint.navigate" = "↑↓ navigate"; "spotlight.hint.open" = "↩ open"; @@ -384,6 +556,20 @@ "spotlight.action.refresh.title" = "Refresh data"; "spotlight.action.refresh.subtitle" = "Re-bootstrap skills, sources, and agents from sidecar"; +"spotlight.action.usageScan.title" = "Re-scan usage"; +"spotlight.action.usageScan.subtitle" = "Scan recent transcripts for calls and tokens"; +"spotlight.action.showBundles.title" = "Show bundles"; +"spotlight.action.showBundles.subtitle" = "Filter the matrix to composite packages"; +"spotlight.action.showSkills.title" = "Show skills"; +"spotlight.action.showSkills.subtitle" = "Filter the matrix to skills"; +"spotlight.action.showCLI.title" = "Show CLI"; +"spotlight.action.showCLI.subtitle" = "Filter the matrix to command-line tools"; +"spotlight.action.showMCP.title" = "Show MCP"; +"spotlight.action.showMCP.subtitle" = "Filter the matrix to MCP servers"; +"spotlight.action.showBrokenLinks.title" = "Show broken links"; +"spotlight.action.showBrokenLinks.subtitle" = "Filter the matrix to capabilities with bad app links"; +"spotlight.action.showInactive.title" = "Show inactive"; +"spotlight.action.showInactive.subtitle" = "Filter the matrix to capabilities not active in Claude or Codex"; "spotlight.action.linkHealth.title" = "Open Link Health"; "spotlight.action.linkHealth.subtitle" = "Inspect SSOT ↔ symlink integrity"; "spotlight.action.updates.title" = "Open Updates"; @@ -507,7 +693,17 @@ "settings.sync.soon" = "SOON"; "settings.sync.push" = "Push"; "settings.sync.pull" = "Pull"; +"settings.sync.status" = "Status"; "settings.sync.done" = "%@ via %@ done"; +"settings.sync.noDetails" = "No sync details returned."; +"settings.sync.result.success" = "Sync complete"; +"settings.sync.result.failure" = "Sync failed"; +"settings.sync.result.unavailable" = "Provider unavailable"; +"settings.sync.result.unknown" = "Sync response"; +"settings.sync.detail.exitCode" = "Exit code %d"; +"settings.sync.detail.local" = "Local: %@"; +"settings.sync.detail.remote" = "Remote: %@"; +"settings.sync.detail.count" = "%d items"; "settings.sources.title" = "SOURCES"; "settings.sources.summary" = "%d of %d sources enabled"; diff --git a/swift-app/Sources/Popskill/Resources/zh-Hans.lproj/Localizable.strings b/swift-app/Sources/Popskill/Resources/zh-Hans.lproj/Localizable.strings index 855ee07..778c885 100644 --- a/swift-app/Sources/Popskill/Resources/zh-Hans.lproj/Localizable.strings +++ b/swift-app/Sources/Popskill/Resources/zh-Hans.lproj/Localizable.strings @@ -157,6 +157,15 @@ "Installed Components" = "已安装组件"; "Component Tree" = "组件树"; "package.componentSummary" = "%d 个组件 · %d 个已安装 · %d 个必需"; +"package.componentComposition.cli.one" = "%d 个 CLI"; +"package.componentComposition.cli.other" = "%d 个 CLI"; +"package.componentComposition.mcp.one" = "%d 个 MCP"; +"package.componentComposition.mcp.other" = "%d 个 MCP"; +"package.componentComposition.skill.one" = "%d 项 Skill"; +"package.componentComposition.skill.other" = "%d 项 Skill"; +"package.componentComposition.agent.one" = "%d 个 Agent"; +"package.componentComposition.agent.other" = "%d 个 Agent"; +"package.componentComposition.empty" = "无组件"; "Directory" = "目录"; "Identifier" = "标识符"; "Hash" = "哈希"; @@ -304,20 +313,46 @@ "sidebar.section.control" = "操控台"; "sidebar.section.sources" = "来源"; "sidebar.section.maintenance" = "维护"; +"sidebar.matrixFilters" = "筛选"; +"sidebar.matrixTypes" = "类型"; +"sidebar.sync" = "同步"; +"sidebar.sync.settings" = "设置"; +"sidebar.sync.openSettings" = "打开同步设置"; +"sidebar.sync.never" = "尚未同步"; +"sidebar.sync.last" = "上次 %@"; // S3 新增 — 矩阵主视图 + Inspector -"matrix.subtitle" = "%d 项能力 · %d 个已启用开关"; +"matrix.subtitle" = "%d 个套装 · %d 项能力 · %d 个已启用开关"; "matrix.search.placeholder" = "按名称、触发词或目录过滤"; +"matrix.metric.capabilities" = "能力总数"; +"matrix.metric.claudeActive" = "Claude 已激活"; +"matrix.metric.codexActive" = "Codex 已激活"; +"matrix.metric.stubs" = "占位 (stub)"; +"matrix.metric.brokenLinks" = "断链"; +"matrix.metric.tokenUsage" = "近30天 token"; +"matrix.sort.typeDescending" = "排序:类型 ↓"; +"matrix.sort.typeAscending" = "排序:类型 ↑"; +"matrix.sort.nameAscending" = "排序:名称 A-Z"; +"matrix.sort.nameDescending" = "排序:名称 Z-A"; +"matrix.sort.callsDescending" = "排序:调用 ↓"; +"matrix.sort.tokensDescending" = "排序:Tokens ↓"; +"matrix.sort.recentDescending" = "排序:最近使用 ↓"; +"matrix.sort.help" = "选择矩阵行排序方式"; "matrix.col.capability" = "能力"; "matrix.col.source" = "来源"; +"matrix.col.version" = "版本"; +"matrix.col.tokens" = "Tokens"; +"matrix.col.calls" = "调用"; "matrix.filter.all" = "全部"; "matrix.filter.updates" = "可更新"; +"matrix.filter.brokenLinks" = "断链"; "matrix.filter.claudeOnly" = "仅 Claude"; "matrix.filter.codexOnly" = "仅 Codex"; "matrix.filter.inactive" = "未启用"; "matrix.type.all" = "所有类型"; +"matrix.type.bundle" = "Bundle"; "matrix.type.skill" = "Skill"; "matrix.type.agent" = "Agent"; "matrix.type.cli" = "CLI"; @@ -329,6 +364,8 @@ "matrix.group.ungrouped" = "其他"; +"matrix.row.brokenLinkBadge" = "断链"; +"matrix.row.brokenLinkHelp" = "至少一个应用链接已断开"; "matrix.row.updateBadge" = "可更新"; "matrix.row.noSummary" = "暂无说明"; "matrix.row.menu.inspect" = "打开 Inspector"; @@ -336,6 +373,48 @@ "matrix.row.menu.revealInFinder" = "在 Finder 中显示"; "matrix.row.menu.help" = "更多操作"; "matrix.group.coverageHelp" = "%@:已启用 %d / %d"; +"matrix.package.coverageHelp" = "%@ 覆盖:%d / %d 个组件"; +"matrix.package.coverage.percent" = "%d%%"; +"matrix.package.coverage.stubbed" = "%d 占位"; +"matrix.package.coverage.off" = "%d 未启用"; +"matrix.package.coverage.complete" = "已覆盖"; +"matrix.package.expand" = "展开组件"; +"matrix.package.collapse" = "收起组件"; +"matrix.package.health.active" = "完整"; +"matrix.package.health.partial" = "部分"; +"matrix.package.health.inactive" = "未安装"; +"matrix.package.health.blocked" = "缺必需"; +"matrix.package.component.required" = "必需"; +"matrix.package.component.optional" = "可选"; +"matrix.package.component.enabledHelp" = "%@ 已启用"; +"matrix.package.component.partialHelp" = "%@ 是占位或待恢复"; +"matrix.package.component.offHelp" = "%@ 未启用"; +"matrix.package.component.unsupportedHelp" = "%@ 暂不支持这个组件"; +"matrix.package.component.state.active" = "已激活"; +"matrix.package.component.state.stub" = "占位"; +"matrix.package.component.state.off" = "未启用"; +"matrix.package.component.state.unsupported" = "不可用"; +"matrix.package.component.calls" = "%@ 次"; +"matrix.package.missingRequired" = "缺 %d 个必需"; +"matrix.package.config.secret" = "Keychain 密钥"; +"matrix.package.usage.tokens" = "近30天 tokens"; +"matrix.package.usage.calls" = "近30天调用"; +"matrix.package.usage.window" = "最近 %d 天的本机 transcript 归因统计。"; +"matrix.package.usage.lastUsed" = "最近使用 %@"; +"matrix.package.usage.trend" = "近30天调用趋势"; +"matrix.package.usage.peak" = "峰值 %@ 次 · %@"; +"matrix.package.usage.empty" = "已扫描用量,但这个套装匹配到的 %d 个本地 skill 暂无归因记录。"; +"matrix.package.usage.notScanned" = "还没有扫描 transcript,用量聚合暂不可用。"; +"matrix.package.usage.scanning" = "正在扫描本机 transcript…"; +"matrix.package.usage.topComponents" = "最常用组件"; +"matrix.package.usage.componentCalls" = "%d 次调用"; +"matrix.skill.usage.empty" = "已扫描用量,但这个 skill 暂无归因记录。"; +"matrix.skill.usage.notScanned" = "还没有扫描 transcript,用量暂不可用。"; +"matrix.skill.bundle.open" = "打开套装"; +"matrix.skill.bundle.companions" = "%d 个同伴技能"; +"matrix.skill.bundle.openCompanion" = "打开 %@"; +"matrix.package.paths.empty" = "没有匹配到本地 skill 真身。"; +"matrix.package.paths.more" = "还有 %d 个匹配 skill 未展开。"; "matrix.empty.title" = "还没有能力"; "matrix.empty.body" = "从数据源安装第一个 skill,或等 sidecar 初始化完成。"; @@ -344,12 +423,100 @@ "matrix.inspector.empty.title" = "选个能力看详情"; "matrix.inspector.empty.body" = "点左边的能力,这里会显示描述、部署链接和来源。"; "matrix.inspector.close" = "关闭面板"; +"matrix.inspector.tab.overview" = "概览"; +"matrix.inspector.tab.readme" = "README"; +"matrix.inspector.tab.usage" = "用量"; +"matrix.inspector.tab.version" = "版本"; +"matrix.inspector.tab.paths" = "路径"; +"matrix.inspector.tab.sync" = "同步"; +"matrix.inspector.tab.metadata" = "元信息"; + +"matrix.inspector.header.calls" = "%@ 次调用"; +"matrix.inspector.header.tokens" = "%@ tokens"; "matrix.inspector.section.summary" = "简介"; +"matrix.inspector.section.actions" = "操作"; +"matrix.inspector.section.readme" = "README"; "matrix.inspector.section.triggers" = "触发场景"; "matrix.inspector.section.apps" = "应用启用"; +"matrix.inspector.section.coverage" = "覆盖"; +"matrix.inspector.section.usage" = "用量"; +"matrix.inspector.section.components" = "组件"; +"matrix.inspector.section.bundle" = "所属套装"; +"matrix.inspector.section.activation" = "批量启用"; +"matrix.inspector.section.machine" = "这台机器"; +"matrix.inspector.section.source" = "来源与路径"; +"matrix.inspector.section.config" = "配置"; +"matrix.inspector.section.paths" = "本地路径"; +"matrix.inspector.section.version" = "版本"; +"matrix.inspector.section.sync" = "同步"; "matrix.inspector.section.deployment" = "位置与链接"; "matrix.inspector.section.meta" = "元信息"; +"matrix.package.action.rescanUsage" = "重新扫描用量"; +"matrix.package.action.checkUpdates" = "检查更新"; +"matrix.package.action.openSource" = "打开来源"; +"matrix.package.action.revealInFinder" = "定位本地 Skill"; +"matrix.package.action.activateClaude" = "全部激活到 Claude"; +"matrix.package.action.activateCodex" = "全部激活到 Codex"; +"matrix.package.action.activateApp" = "全部激活"; +"matrix.package.activation.empty" = "这个套装没有可批量启用的本地 skill。"; +"matrix.package.activation.complete" = "匹配到的本地 skill 已全部激活到 Claude 和 Codex。"; +"matrix.package.activation.remaining" = "Claude 还差 %d 个 · Codex 还差 %d 个"; +"matrix.machine.firstActivated" = "首次激活"; +"matrix.machine.lastUsed" = "最近使用"; +"matrix.machine.tokens" = "近30天 tokens"; +"matrix.machine.calls" = "近30天调用"; +"matrix.machine.topComponent" = "最常用组件"; +"matrix.machine.size" = "磁盘占用"; +"matrix.source.repository" = "仓库"; +"matrix.source.directory" = "目录"; +"matrix.source.localStore" = "本地真身"; +"matrix.source.type" = "来源类型"; +"matrix.source.author" = "作者"; +"matrix.source.license" = "License"; +"matrix.source.homepage" = "主页"; +"matrix.source.requires" = "依赖命令"; +"matrix.source.readme" = "SKILL.md"; +"matrix.skill.action.openReadme" = "编辑 prompt"; +"matrix.skill.action.checkUpdates" = "检查更新"; +"matrix.skill.action.openSource" = "打开来源"; +"matrix.skill.action.revealInFinder" = "在 Finder 中显示"; +"matrix.skill.paths.readme" = "SKILL.md"; +"matrix.package.version.strategy" = "策略"; +"matrix.package.version.branch" = "分支"; +"matrix.package.version.hash" = "Hash"; +"matrix.package.version.untracked" = "未跟踪"; +"matrix.package.version.components" = "组件版本"; +"matrix.skill.manifest.version" = "版本"; +"matrix.skill.manifest.author" = "作者"; +"matrix.skill.manifest.license" = "License"; +"matrix.skill.manifest.requires" = "依赖命令"; +"matrix.skill.manifest.requires.available" = "可用"; +"matrix.skill.manifest.requires.missing" = "缺失"; +"matrix.package.sync.upToDate" = "已是最新"; +"matrix.package.sync.pending" = "%d 个待更新"; +"matrix.package.sync.status" = "同步状态"; +"matrix.package.sync.upstream" = "上游更新"; +"matrix.package.sync.source" = "来源"; +"matrix.package.sync.provider" = "同步方式"; +"matrix.package.sync.lastSync" = "上次同步"; +"matrix.package.sync.linkHealth" = "链接健康"; +"matrix.package.sync.linkHealthNotScanned" = "未扫描"; +"matrix.package.sync.linkHealthSummary" = "%d 正常 · %d 断链 · %d 未启用"; +"matrix.package.sync.linkHealthEmpty" = "这个套装还没有匹配到已部署技能链接。"; +"matrix.package.sync.linkHealthNotScannedDetail" = "运行链接健康检查,查看这个套装在 Claude 和 Codex 两端的链接状态。"; +"matrix.package.sync.noDeployment" = "暂无部署信息"; +"matrix.package.sync.checking" = "正在检查上游更新..."; +"matrix.package.sync.checked" = "已检查 %@"; +"matrix.package.sync.notChecked" = "尚未检查"; +"matrix.package.sync.clean" = "没有检测到套装组件更新。"; +"matrix.package.sync.more" = "还有 %d 个更新未展开。"; +"matrix.readme.loading" = "正在加载 README 预览..."; +"matrix.readme.showing" = "当前显示 %@"; +"matrix.readme.openFile" = "打开 SKILL.md"; +"matrix.readme.reload" = "重新加载 README"; +"matrix.readme.error" = "README 不可用:%@"; +"matrix.readme.truncated" = "预览已截断。"; "matrix.inspector.deployment.ssot" = "真身 (SSOT)"; "matrix.inspector.deployment.strategy" = "部署策略 · %@"; @@ -362,6 +529,7 @@ "matrix.inspector.meta.directory" = "目录"; "matrix.inspector.meta.sourceType" = "来源类型"; +"matrix.inspector.meta.packageType" = "套装类型"; "matrix.inspector.meta.installedAt" = "安装于"; "matrix.inspector.meta.updatedAt" = "更新于"; "matrix.inspector.meta.size" = "大小"; @@ -373,10 +541,14 @@ "spotlight.clear" = "清空"; "spotlight.section.capabilities" = "能力"; +"spotlight.section.recent" = "最近使用"; "spotlight.section.actions" = "操作"; +"spotlight.bundle.matchedComponents" = "匹配组件:%@"; +"spotlight.recentUsage" = "最近 %@ · %@ 次调用"; +"spotlight.recentUsage.unknown" = "刚刚"; "spotlight.empty.title" = "没有匹配"; -"spotlight.empty.body" = "试试名字、触发词,或按 ↑↓ 挑一条最近更新的能力。"; +"spotlight.empty.body" = "试试名字、触发词,或按 ↑↓ 挑一条最近使用的能力。"; "spotlight.hint.navigate" = "↑↓ 导航"; "spotlight.hint.open" = "↩ 打开"; @@ -384,6 +556,20 @@ "spotlight.action.refresh.title" = "刷新数据"; "spotlight.action.refresh.subtitle" = "重新从 sidecar 拉 skills / 数据源 / agents"; +"spotlight.action.usageScan.title" = "重新扫描用量"; +"spotlight.action.usageScan.subtitle" = "扫描最近 transcript,刷新调用与 tokens"; +"spotlight.action.showBundles.title" = "查看套装"; +"spotlight.action.showBundles.subtitle" = "把矩阵过滤到复合能力包"; +"spotlight.action.showSkills.title" = "查看 Skill"; +"spotlight.action.showSkills.subtitle" = "把矩阵过滤到 Skill 能力"; +"spotlight.action.showCLI.title" = "查看 CLI"; +"spotlight.action.showCLI.subtitle" = "把矩阵过滤到命令行工具"; +"spotlight.action.showMCP.title" = "查看 MCP"; +"spotlight.action.showMCP.subtitle" = "把矩阵过滤到 MCP 服务"; +"spotlight.action.showBrokenLinks.title" = "查看断链"; +"spotlight.action.showBrokenLinks.subtitle" = "把矩阵过滤到应用链接异常的能力"; +"spotlight.action.showInactive.title" = "查看未启用"; +"spotlight.action.showInactive.subtitle" = "把矩阵过滤到未在 Claude 或 Codex 启用的能力"; "spotlight.action.linkHealth.title" = "打开链接健康"; "spotlight.action.linkHealth.subtitle" = "检查 SSOT 与 symlink 的健康"; "spotlight.action.updates.title" = "打开更新列表"; @@ -507,7 +693,17 @@ "settings.sync.soon" = "稍后"; "settings.sync.push" = "推送"; "settings.sync.pull" = "拉取"; +"settings.sync.status" = "状态"; "settings.sync.done" = "已通过 %2$@ %1$@"; +"settings.sync.noDetails" = "同步没有返回详细信息。"; +"settings.sync.result.success" = "同步完成"; +"settings.sync.result.failure" = "同步失败"; +"settings.sync.result.unavailable" = "同步方式不可用"; +"settings.sync.result.unknown" = "同步响应"; +"settings.sync.detail.exitCode" = "退出码 %d"; +"settings.sync.detail.local" = "本地:%@"; +"settings.sync.detail.remote" = "远端:%@"; +"settings.sync.detail.count" = "%d 项"; "settings.sources.title" = "数据源"; "settings.sources.summary" = "%d / %d 个数据源已启用"; diff --git a/swift-app/Sources/Popskill/Storage/TranscriptUsageScanner.swift b/swift-app/Sources/Popskill/Storage/TranscriptUsageScanner.swift index 055215f..779d51f 100644 --- a/swift-app/Sources/Popskill/Storage/TranscriptUsageScanner.swift +++ b/swift-app/Sources/Popskill/Storage/TranscriptUsageScanner.swift @@ -2,12 +2,16 @@ import Foundation struct TranscriptUsageScanner { private let projectsURL: URL + private let referenceDate: Date + private let recentWindowDays: Int - init(projectsURL: URL? = nil) { + init(projectsURL: URL? = nil, referenceDate: Date = Date(), recentWindowDays: Int = 30) { self.projectsURL = projectsURL ?? URL(fileURLWithPath: NSHomeDirectory()) .appendingPathComponent(".claude") .appendingPathComponent("projects") + self.referenceDate = referenceDate + self.recentWindowDays = max(1, recentWindowDays) } func scan() throws -> UsageSummary { @@ -15,12 +19,21 @@ struct TranscriptUsageScanner { var modelStats: [String: ModelUsageStat] = [:] var skillStats: [String: SkillUsageStat] = [:] var sessionStats: [String: SessionUsageStat] = [:] + let recentStart = referenceDate.addingTimeInterval(-Double(recentWindowDays) * 24 * 60 * 60) + var recentSummary = UsageWindowSummary( + days: recentWindowDays, + startedAt: recentStart, + endedAt: referenceDate + ) + var recentModelStats: [String: ModelUsageStat] = [:] + var recentSkillStats: [String: SkillUsageStat] = [:] guard let enumerator = FileManager.default.enumerator( at: projectsURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) else { + summary.recent30Days = recentSummary return summary } @@ -31,23 +44,17 @@ struct TranscriptUsageScanner { summary: &summary, modelStats: &modelStats, skillStats: &skillStats, - sessionStats: &sessionStats + sessionStats: &sessionStats, + recentSummary: &recentSummary, + recentModelStats: &recentModelStats, + recentSkillStats: &recentSkillStats, + recentStart: recentStart ) } summary.sessions = sessionStats.count - summary.modelStats = modelStats.values.sorted { - if $0.totalTokens == $1.totalTokens { - return $0.model < $1.model - } - return $0.totalTokens > $1.totalTokens - } - summary.skillStats = skillStats.values.sorted { - if $0.totalTokens == $1.totalTokens { - return $0.skillID < $1.skillID - } - return $0.totalTokens > $1.totalTokens - } + summary.modelStats = sortedModelStats(modelStats) + summary.skillStats = sortedSkillStats(skillStats) summary.recentSessions = sessionStats.values.sorted { switch ($0.lastActivityAt, $1.lastActivityAt) { case let (left?, right?): @@ -63,6 +70,9 @@ struct TranscriptUsageScanner { return $0.sessionID < $1.sessionID } } + recentSummary.modelStats = sortedModelStats(recentModelStats) + recentSummary.skillStats = sortedSkillStats(recentSkillStats) + summary.recent30Days = recentSummary return summary } @@ -71,7 +81,11 @@ struct TranscriptUsageScanner { summary: inout UsageSummary, modelStats: inout [String: ModelUsageStat], skillStats: inout [String: SkillUsageStat], - sessionStats: inout [String: SessionUsageStat] + sessionStats: inout [String: SessionUsageStat], + recentSummary: inout UsageWindowSummary, + recentModelStats: inout [String: ModelUsageStat], + recentSkillStats: inout [String: SkillUsageStat], + recentStart: Date ) throws { let projectName = projectName(for: fileURL) let handle = try FileHandle(forReadingFrom: fileURL) @@ -91,7 +105,11 @@ struct TranscriptUsageScanner { summary: &summary, modelStats: &modelStats, skillStats: &skillStats, - sessionStats: &sessionStats + sessionStats: &sessionStats, + recentSummary: &recentSummary, + recentModelStats: &recentModelStats, + recentSkillStats: &recentSkillStats, + recentStart: recentStart ) buffer.removeSubrange(buffer.startIndex..= recentStart && $0 <= referenceDate }) == true { + let dayStart = timestamp.map(normalizedDayStart) ?? normalizedDayStart(referenceDate) + recentSummary.addUsage( + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationTokens: cacheCreationTokens, + cacheReadTokens: cacheReadTokens, + dayStart: dayStart + ) + recordModelUsage( + model: model, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationTokens: cacheCreationTokens, + cacheReadTokens: cacheReadTokens, + modelStats: &recentModelStats + ) + if let attributionSkill { + recentSummary.attributedSkillUsageEvents += 1 + recordSkillUsage( + skillID: attributionSkill, + sourcePlugin: attributionPlugin, + timestamp: timestamp, + dayStart: dayStart, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationTokens: cacheCreationTokens, + cacheReadTokens: cacheReadTokens, + skillStats: &recentSkillStats + ) + } + } + } + + private func recordModelUsage( + model: String, + inputTokens: Int64, + outputTokens: Int64, + cacheCreationTokens: Int64, + cacheReadTokens: Int64, + modelStats: inout [String: ModelUsageStat] + ) { var stat = modelStats[model] ?? ModelUsageStat( model: model, usageEvents: 0, @@ -207,30 +301,62 @@ struct TranscriptUsageScanner { stat.cacheCreationTokens += cacheCreationTokens stat.cacheReadTokens += cacheReadTokens modelStats[model] = stat + } - if let attributionSkill = attributionIdentifier(object["attributionSkill"]) { - summary.attributedSkillUsageEvents += 1 - var skillStat = skillStats[attributionSkill] ?? SkillUsageStat( - skillID: attributionSkill, - sourcePlugin: stringValue(object["attributionPlugin"]), - usageEvents: 0, - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - lastUsedAt: nil - ) - if skillStat.sourcePlugin == nil { - skillStat.sourcePlugin = stringValue(object["attributionPlugin"]) + private func recordSkillUsage( + skillID: String, + sourcePlugin: String?, + timestamp: Date?, + dayStart: Date?, + inputTokens: Int64, + outputTokens: Int64, + cacheCreationTokens: Int64, + cacheReadTokens: Int64, + skillStats: inout [String: SkillUsageStat] + ) { + var skillStat = skillStats[skillID] ?? SkillUsageStat( + skillID: skillID, + sourcePlugin: sourcePlugin, + usageEvents: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: nil + ) + if skillStat.sourcePlugin == nil { + skillStat.sourcePlugin = sourcePlugin + } + skillStat.addUsage( + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationTokens: cacheCreationTokens, + cacheReadTokens: cacheReadTokens, + timestamp: timestamp, + dayStart: dayStart + ) + skillStats[skillID] = skillStat + } + + private func normalizedDayStart(_ timestamp: Date) -> Date { + Self.utcCalendar.startOfDay(for: timestamp) + } + + private func sortedModelStats(_ stats: [String: ModelUsageStat]) -> [ModelUsageStat] { + stats.values.sorted { + if $0.totalTokens == $1.totalTokens { + return $0.model < $1.model } - skillStat.addUsage( - inputTokens: inputTokens, - outputTokens: outputTokens, - cacheCreationTokens: cacheCreationTokens, - cacheReadTokens: cacheReadTokens, - timestamp: timestamp - ) - skillStats[attributionSkill] = skillStat + return $0.totalTokens > $1.totalTokens + } + } + + private func sortedSkillStats(_ stats: [String: SkillUsageStat]) -> [SkillUsageStat] { + stats.values.sorted { + if $0.totalTokens == $1.totalTokens { + return $0.skillID < $1.skillID + } + return $0.totalTokens > $1.totalTokens } } @@ -325,4 +451,9 @@ struct TranscriptUsageScanner { private static let readChunkSize = 64 * 1024 private static let newlineData = Data([0x0A]) private static let carriageReturnByte: UInt8 = 0x0D + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + return calendar + }() } diff --git a/swift-app/Sources/Popskill/Views/BackupsView.swift b/swift-app/Sources/Popskill/Views/BackupsView.swift index 2831a50..e92e4a3 100644 --- a/swift-app/Sources/Popskill/Views/BackupsView.swift +++ b/swift-app/Sources/Popskill/Views/BackupsView.swift @@ -5,6 +5,7 @@ import SwiftUI /// with date headers and per-row restore / delete. Restore picks Claude by /// default since most users only have Claude wired; v0.4 will add a target /// chooser. +@MainActor struct BackupsView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization diff --git a/swift-app/Sources/Popskill/Views/IdleView.swift b/swift-app/Sources/Popskill/Views/IdleView.swift index 1732381..984cdfe 100644 --- a/swift-app/Sources/Popskill/Views/IdleView.swift +++ b/swift-app/Sources/Popskill/Views/IdleView.swift @@ -3,6 +3,7 @@ import SwiftUI /// Idle — skills that are toggled off across every app **and** haven't been /// touched (install / update / use) for ≥ 60 days. Surfaces the standard /// majia "卸载也安全" three-strategy decision per row. +@MainActor struct IdleView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization @@ -143,7 +144,7 @@ struct IdleView: View { do { let stub = try await store.client.stub(skillID: skill.id) - store.stubs.append(stub) + store.upsertStub(stub) store.skills.removeAll { $0.id == skill.id } } catch { store.errorMessage = error.localizedDescription diff --git a/swift-app/Sources/Popskill/Views/InsightsView.swift b/swift-app/Sources/Popskill/Views/InsightsView.swift index 957e0e4..f763ab7 100644 --- a/swift-app/Sources/Popskill/Views/InsightsView.swift +++ b/swift-app/Sources/Popskill/Views/InsightsView.swift @@ -6,6 +6,7 @@ import SwiftUI /// token consumption inferred from `~/.claude/projects/**/*.jsonl`. The scan /// runs off the main thread; the section degrades gracefully when there's no /// `~/.claude/projects/` directory yet. +@MainActor struct InsightsView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization diff --git a/swift-app/Sources/Popskill/Views/InspectorPane.swift b/swift-app/Sources/Popskill/Views/InspectorPane.swift index 31b5586..de2d246 100644 --- a/swift-app/Sources/Popskill/Views/InspectorPane.swift +++ b/swift-app/Sources/Popskill/Views/InspectorPane.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Right-pane inspector for the matrix. Renders a single @@ -5,97 +6,1844 @@ import SwiftUI /// the full set of sections (summary / triggers / apps / deployment / /// metadata); other kinds gracefully omit the irrelevant pieces (an agent /// has no SSOT symlink to chart, a CLI has no per-app toggle). +@MainActor struct InspectorPane: View { @Bindable var store: PopskillStore let capability: MatrixCapability @Environment(\.popskillLocalization) private var localization + @State private var selectedTab: InspectorTab = .overview + @State private var linkHealthScanInFlight: Bool = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { header - if !primaryDescription.isEmpty { - summarySection + tabPicker + if let package = capability.package { + packageContent(package) + } else { + capabilityContent } - if let scenarios = capability.triggerScenarios, !scenarios.isEmpty { - triggerSection(scenarios: scenarios) + } + .padding(.horizontal, 18) + .padding(.vertical, 18) + } + .background(Color.popCardBackground.opacity(0.72)) + .onAppear { + normalizeSelectedTab() + } + .onChange(of: capability.id) { _, _ in + selectedTab = .overview + normalizeSelectedTab() + } + } + + // MARK: Header + + private var header: some View { + HStack(alignment: .top, spacing: 10) { + InitialAvatarView(name: capability.name, identifier: capability.id) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(capability.name) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.popLabel) + .lineLimit(2) + kindChip + } + Text(capability.sourceLabel) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + headerChipStrip + } + Spacer() + Button { + store.closeInspector() + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.popSecondaryLabel) + .frame(width: 22, height: 22) + .background(Color.popSubtleFill, in: Circle()) + } + .buttonStyle(.plain) + .help(localization.string("matrix.inspector.close")) + } + } + + @ViewBuilder + private var headerChipStrip: some View { + let chips = inspectorHeaderChips() + if !chips.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 5) { + ForEach(chips) { chip in + Text(chip.title) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(chip.tint) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 2.5) + .background(chip.tint.opacity(0.10), in: Capsule()) + } + } + } + .padding(.top, 3) + } + } + + private func inspectorHeaderChips() -> [InspectorHeaderChip] { + if let package = capability.package { + return packageHeaderChips(package) + } + if let skill = selectedSkill { + return skillHeaderChips(skill) + } + return [] + } + + private func packageHeaderChips(_ package: CapabilityPackage) -> [InspectorHeaderChip] { + var chips: [InspectorHeaderChip] = [] + for app in [TargetApp.claude, .codex] { + if let coverage = capability.appCoverage[app], coverage.total > 0 { + chips.append(InspectorHeaderChip( + id: "\(app.rawValue)-coverage", + title: "\(app.title) \(coverage.label)", + tint: coverage.enabled > 0 ? app.inspectorAccentColor : Color.popTertiaryLabel + )) + } + } + if let snapshot = package.usageSnapshot(using: store.usageSummary, skills: store.skills), snapshot.hasUsage { + chips.append(contentsOf: usageHeaderChips(calls: snapshot.usageEvents, tokens: snapshot.totalTokens)) + } + return chips + } + + private func skillHeaderChips(_ skill: Skill) -> [InspectorHeaderChip] { + var chips = TargetApp.quickToggleSupported.map { app in + let isOn = skill.apps.isEnabled(app) + let stateKey = isOn ? "matrix.package.component.state.active" : "matrix.package.component.state.off" + return InspectorHeaderChip( + id: "\(app.rawValue)-state", + title: "\(app.title) \(localization.string(stateKey))", + tint: isOn ? app.inspectorAccentColor : Color.popTertiaryLabel + ) + } + if let snapshot = skill.usageSnapshot(using: store.usageSummary), snapshot.hasUsage { + chips.append(contentsOf: usageHeaderChips(calls: snapshot.usageEvents, tokens: snapshot.totalTokens)) + } + return chips + } + + private func usageHeaderChips(calls: Int, tokens: Int64) -> [InspectorHeaderChip] { + [ + InspectorHeaderChip( + id: "calls", + title: localization.string("matrix.inspector.header.calls", UsageDisplayFormatter.compactCount(calls)), + tint: Color.popSectionGreen + ), + InspectorHeaderChip( + id: "tokens", + title: localization.string("matrix.inspector.header.tokens", UsageDisplayFormatter.compactTokens(tokens)), + tint: Color.accentColor + ) + ] + } + + private var tabPicker: some View { + Picker("", selection: $selectedTab) { + ForEach(availableTabs) { tab in + Text(localization.string(tab.titleKey)).tag(tab) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .onChange(of: selectedTab) { _, _ in + normalizeSelectedTab() + } + } + + @ViewBuilder + private func packageContent(_ package: CapabilityPackage) -> some View { + switch selectedTab { + case .overview: + packageSummarySection(package) + packageActivationSection(package) + packageActionsSection(package) + packageCoverageSection(package) + packageMachineSection(package) + packageComponentsSection(package) + case .readme: + if let skill = readmeSkill(for: package) { + readmePreviewSection( + skill: skill, + context: localization.string("matrix.readme.showing", skill.name) + ) + } + case .usage: + packageUsageSection(package) + case .version: + if !package.configSchema.isEmpty { + packageConfigSection(package) + } + packageLocalPathsSection(package) + packageVersionSection(package) + packageMetadataSection(package) + case .paths: + packageLocalPathsSection(package) + case .sync: + packageActionsSection(package) + packageSyncStatusSection(package) + packageSyncSection(package) + case .metadata: + packageMetadataSection(package) + } + } + + @ViewBuilder + private var capabilityContent: some View { + switch selectedTab { + case .overview: + if !primaryDescription.isEmpty { + summarySection + } + if let skill = selectedSkill { + skillActionsSection(skill) + skillMachineSection(skill) + skillSourceSection(skill) + } + if let scenarios = capability.triggerScenarios, !scenarios.isEmpty { + triggerSection(scenarios: scenarios) + } + if let skill = selectedSkill { + skillBundleSection(skill) + } + appsSection + case .readme: + if let skill = selectedSkill { + readmePreviewSection(skill: skill) + } + case .usage: + if let skill = selectedSkill { + skillUsageSection(skill) + } + case .version: + if let skill = selectedSkill { + skillVersionSection(skill) + } else { + metadataSection + } + case .paths: + if let skill = selectedSkill { + skillPathsSection(skill) + } else { + metadataSection + } + case .metadata: + metadataSection + case .sync: + metadataSection + } + } + + private var availableTabs: [InspectorTab] { + if let package = capability.package { + var tabs: [InspectorTab] = [.overview] + if readmeSkill(for: package) != nil { + tabs.append(.readme) + } + tabs.append(contentsOf: [.usage, .version, .sync]) + return tabs + } + + var tabs: [InspectorTab] = [.overview] + if selectedSkill != nil { + tabs.append(contentsOf: [.readme, .usage, .version, .paths]) + } + if selectedSkill == nil { + tabs.append(.metadata) + } + return tabs + } + + private func normalizeSelectedTab() { + guard !availableTabs.contains(selectedTab) else { return } + selectedTab = .overview + } + + private func packageActionsSection(_ package: CapabilityPackage) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.actions", accent: .accentColor) + LazyVGrid(columns: Self.actionGridColumns, alignment: .leading, spacing: 8) { + inspectorActionButton( + titleKey: "matrix.package.action.rescanUsage", + systemImage: "chart.bar.doc.horizontal", + inFlight: store.usageScanInFlight, + disabled: store.usageScanInFlight + ) { + Task { await store.refreshUsageScan() } + } + + inspectorActionButton( + titleKey: "matrix.package.action.checkUpdates", + systemImage: "arrow.clockwise", + inFlight: store.updatesRefreshInFlight, + disabled: store.updatesRefreshInFlight + ) { + Task { await store.refreshUpdates(force: true) } + } + + sourceAction(for: package) + + inspectorActionButton( + titleKey: "matrix.package.action.revealInFinder", + systemImage: "folder", + disabled: firstRevealableSkillURL(for: package) == nil + ) { + if let url = firstRevealableSkillURL(for: package) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + } + } + } + + @ViewBuilder + private func sourceAction(for package: CapabilityPackage) -> some View { + if let url = package.sourceURL { + Link(destination: url) { + inspectorActionLabel( + titleKey: "matrix.package.action.openSource", + systemImage: "arrow.up.right.square" + ) + } + .buttonStyle(.plain) + } else { + inspectorActionButton( + titleKey: "matrix.package.action.openSource", + systemImage: "arrow.up.right.square", + disabled: true + ) {} + } + } + + private func skillActionsSection(_ skill: Skill) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.actions", accent: .accentColor) + LazyVGrid(columns: Self.actionGridColumns, alignment: .leading, spacing: 8) { + inspectorActionButton( + titleKey: "matrix.skill.action.openReadme", + systemImage: "square.and.pencil", + disabled: skill.markdownURL == nil + ) { + if let url = skill.markdownURL { + NSWorkspace.shared.open(url) + } + } + + inspectorActionButton( + titleKey: "matrix.skill.action.checkUpdates", + systemImage: "arrow.clockwise", + inFlight: store.updatesRefreshInFlight, + disabled: store.updatesRefreshInFlight + ) { + Task { await store.refreshUpdates(force: true) } + } + + sourceAction(for: skill) + + inspectorActionButton( + titleKey: "matrix.skill.action.revealInFinder", + systemImage: "folder", + disabled: !FileManager.default.fileExists(atPath: skill.localStoreURL.path) + ) { + NSWorkspace.shared.activateFileViewerSelecting([skill.localStoreURL]) + } + } + } + } + + @ViewBuilder + private func skillMachineSection(_ skill: Skill) -> some View { + let metrics = skillMachineMetrics(skill) + if !metrics.isEmpty { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.machine", accent: .popSectionGreen) + LazyVGrid(columns: Self.machineMetricColumns, alignment: .leading, spacing: 8) { + ForEach(metrics) { metric in + machineMetricCard(metric) + } + } + } + } + } + + private func skillMachineMetrics(_ skill: Skill) -> [InspectorMachineMetric] { + var metrics: [InspectorMachineMetric] = [] + let snapshot = skill.usageSnapshot(using: store.usageSummary) + + if let installedAt = skill.installedAt, installedAt > 0 { + metrics.append(InspectorMachineMetric( + id: "installed", + title: localization.string("matrix.machine.firstActivated"), + value: Self.formatTimestamp(installedAt), + tint: Color.popSectionPurple + )) + } + if let lastUsedAt = snapshot?.lastUsedAt { + metrics.append(InspectorMachineMetric( + id: "last-used", + title: localization.string("matrix.machine.lastUsed"), + value: Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()), + tint: Color.popSectionGreen + )) + } + if let snapshot, snapshot.hasUsage { + metrics.append(InspectorMachineMetric( + id: "tokens", + title: localization.string("matrix.machine.tokens"), + value: UsageDisplayFormatter.compactTokens(snapshot.totalTokens), + tint: Color.accentColor + )) + metrics.append(InspectorMachineMetric( + id: "calls", + title: localization.string("matrix.machine.calls"), + value: UsageDisplayFormatter.compactCount(snapshot.usageEvents), + tint: Color.popLabel + )) + } + if let size = skill.sizeBytes, size > 0 { + metrics.append(InspectorMachineMetric( + id: "size", + title: localization.string("matrix.machine.size"), + value: Self.formatBytes(size), + tint: Color.popSecondaryLabel + )) + } + + return metrics + } + + private func machineMetricCard(_ metric: InspectorMachineMetric) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(metric.title) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + Text(metric.value) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(metric.tint) + .lineLimit(1) + .minimumScaleFactor(0.82) + } + .padding(9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(metric.tint.opacity(0.07), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func skillSourceSection(_ skill: Skill) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.source", accent: .popSectionPurple) + VStack(alignment: .leading, spacing: 6) { + ForEach(skillSourceFacts(skill)) { fact in + sourceFactRow(fact) + } + } + } + } + + private func skillSourceFacts(_ skill: Skill) -> [InspectorSourceFact] { + var facts: [InspectorSourceFact] = [ + InspectorSourceFact( + id: "repository", + title: localization.string("matrix.source.repository"), + value: skill.sourceLabel, + icon: "chevron.left.forwardslash.chevron.right" + ), + InspectorSourceFact( + id: "directory", + title: localization.string("matrix.source.directory"), + value: skill.directory, + icon: "folder" + ), + InspectorSourceFact( + id: "local-store", + title: localization.string("matrix.source.localStore"), + value: skill.localStoreURL.path.abbreviatingWithTilde, + icon: "externaldrive" + ) + ] + if let sourceType = skill.sourceType, !sourceType.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + facts.append(InspectorSourceFact( + id: "source-type", + title: localization.string("matrix.source.type"), + value: sourceType, + icon: "tag" + )) + } + if let author = skill.manifest?.author?.trimmingCharacters(in: .whitespacesAndNewlines), !author.isEmpty { + facts.append(InspectorSourceFact( + id: "author", + title: localization.string("matrix.source.author"), + value: author, + icon: "person" + )) + } + if let license = skill.manifest?.license?.trimmingCharacters(in: .whitespacesAndNewlines), !license.isEmpty { + facts.append(InspectorSourceFact( + id: "license", + title: localization.string("matrix.source.license"), + value: license, + icon: "doc.plaintext" + )) + } + if let homepageURL = skill.manifest?.homepageURL { + facts.append(InspectorSourceFact( + id: "homepage", + title: localization.string("matrix.source.homepage"), + value: homepageURL.absoluteString, + icon: "arrow.up.right.square", + url: homepageURL + )) + } + if let manifest = skill.manifest, + let requiredTools = manifest.requiredToolsLabel( + availableLabel: localization.string("matrix.skill.manifest.requires.available"), + missingLabel: localization.string("matrix.skill.manifest.requires.missing") + ) { + facts.append(InspectorSourceFact( + id: "requires", + title: localization.string("matrix.source.requires"), + value: requiredTools, + icon: manifest.hasMissingRequiredTools ? "exclamationmark.triangle.fill" : "checkmark.seal" + )) + } + if let markdownURL = skill.markdownURL { + facts.append(InspectorSourceFact( + id: "readme", + title: localization.string("matrix.source.readme"), + value: markdownURL.path.abbreviatingWithTilde, + icon: "doc.text" + )) + } + return facts + } + + private func sourceFactRow(_ fact: InspectorSourceFact) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: fact.icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.popSecondaryLabel) + .frame(width: 14) + VStack(alignment: .leading, spacing: 2) { + Text(fact.title) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + if let url = fact.url { + Link(fact.value, destination: url) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Color.accentColor) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } else { + Text(fact.value) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Color.popLabel) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + Spacer(minLength: 8) + } + .padding(8) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + @ViewBuilder + private func sourceAction(for skill: Skill) -> some View { + if let url = skill.sourceURL { + Link(destination: url) { + inspectorActionLabel( + titleKey: "matrix.skill.action.openSource", + systemImage: "arrow.up.right.square" + ) + } + .buttonStyle(.plain) + } else { + inspectorActionButton( + titleKey: "matrix.skill.action.openSource", + systemImage: "arrow.up.right.square", + disabled: true + ) {} + } + } + + private func inspectorActionButton( + titleKey: String, + systemImage: String, + inFlight: Bool = false, + disabled: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + inspectorActionLabel(titleKey: titleKey, systemImage: systemImage, inFlight: inFlight) + } + .buttonStyle(.plain) + .disabled(disabled) + .opacity(disabled ? 0.52 : 1) + } + + private func inspectorActionLabel(titleKey: String, systemImage: String, inFlight: Bool = false) -> some View { + HStack(spacing: 7) { + if inFlight { + ProgressView() + .controlSize(.mini) + .frame(width: 14, height: 14) + } else { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .semibold)) + .frame(width: 14) + } + Text(localization.string(titleKey)) + .font(.caption.weight(.semibold)) + .lineLimit(1) + } + .foregroundStyle(Color.popLabel) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var kindChip: some View { + HStack(spacing: 3) { + Image(systemName: capability.kind.symbol) + .font(.system(size: 9, weight: .semibold)) + Text(localization.string(capability.kind.titleKey).uppercased()) + .font(.system(size: 9.5, weight: .bold)) + } + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background(Color.accentColor.opacity(0.12), in: Capsule()) + } + + // MARK: Sections + + private var primaryDescription: String { + capability.summary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + private var selectedSkill: Skill? { + guard let skillID = capability.underlyingSkillID else { + return nil + } + return store.skills.first { $0.id == skillID } + } + + private var summarySection: some View { + VStack(alignment: .leading, spacing: 6) { + SectionHeading(title: "matrix.inspector.section.summary") + Text(primaryDescription) + .font(.callout) + .foregroundStyle(Color.popLabel) + .textSelection(.enabled) + } + } + + private func readmePreviewSection(skill: Skill, context: String? = nil) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.readme", accent: .popSectionPurple) + Spacer() + Button { + Task { await store.loadReadmePreview(for: skill, force: true) } + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(Color.popSecondaryLabel) + .help(localization.string("matrix.readme.reload")) + } + + if let context { + Text(context) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + + readmePreviewBody(for: skill) + } + .task(id: skill.id) { + await store.loadReadmePreview(for: skill) + } + } + + @ViewBuilder + private func readmePreviewBody(for skill: Skill) -> some View { + switch store.readmePreviewState(for: skill) { + case .loaded(let preview): + VStack(alignment: .leading, spacing: 8) { + Text(preview.excerpt) + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(Color.popLabel) + .lineSpacing(2) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 10) { + Button { + NSWorkspace.shared.open(preview.url) + } label: { + Label(localization.string("matrix.readme.openFile"), systemImage: "doc.text") + .font(.caption.weight(.medium)) + } + .buttonStyle(.plain) + + if preview.truncated { + Text(localization.string("matrix.readme.truncated")) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + } + } + .padding(10) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + case .failed(let message): + Text(localization.string("matrix.readme.error", message)) + .font(.caption) + .foregroundStyle(Color.popTertiaryLabel) + case .loading: + readmeLoadingRow + case .none: + readmeLoadingRow + } + } + + private var readmeLoadingRow: some View { + HStack(spacing: 8) { + ProgressView().controlSize(.mini) + Text(localization.string("matrix.readme.loading")) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + } + } + + private func skillUsageSection(_ skill: Skill) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.usage", accent: .accentColor) + usageWindowCaption + + if store.usageScanInFlight { + HStack(spacing: 8) { + ProgressView().controlSize(.mini) + LocalizedText("matrix.package.usage.scanning") + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + } + } else if let snapshot = skill.usageSnapshot(using: store.usageSummary) { + if snapshot.hasUsage { + HStack(spacing: 10) { + packageUsageMetric( + titleKey: "matrix.package.usage.tokens", + value: Self.formatTokens(snapshot.totalTokens), + tint: .accentColor + ) + packageUsageMetric( + titleKey: "matrix.package.usage.calls", + value: "\(snapshot.usageEvents)", + tint: .popSectionGreen + ) + } + if let lastUsedAt = snapshot.lastUsedAt { + Text(localization.string("matrix.package.usage.lastUsed", Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + if !snapshot.dailyStats.isEmpty { + usageTrend(snapshot.dailyStats) + } + } else { + Text(localization.string("matrix.skill.usage.empty")) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + } + } else { + HStack(alignment: .center, spacing: 8) { + Text(localization.string("matrix.skill.usage.notScanned")) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + Spacer(minLength: 8) + Button { + Task { await store.refreshUsageScan() } + } label: { + Label(localization.string("insights.refresh"), systemImage: "chart.bar.doc.horizontal") + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } + } + } + + private func packageSummarySection(_ package: CapabilityPackage) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.summary", accent: .popSectionPurple) + Text(package.summary) + .font(.callout) + .foregroundStyle(Color.popLabel) + .textSelection(.enabled) + + HStack(spacing: 8) { + packagePill( + localization.string(package.health.inspectorTitleKey), + color: package.health.inspectorColor + ) + packagePill( + PackageComponentCompositionFormatter.composition(for: package, localization: localization), + color: Color.popSecondaryLabel + ) + } + } + } + + private func packageCoverageSection(_ package: CapabilityPackage) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.coverage", accent: .popSectionPurple) + HStack(spacing: 10) { + packageCoverageCard(for: .claude, package: package) + packageCoverageCard(for: .codex, package: package) + } + } + } + + private func packageCoverageCard(for app: TargetApp, package: CapabilityPackage) -> some View { + let coverage = package.appCoverageBreakdown(for: app, skills: store.skills) + return VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 5) { + Image(systemName: app.symbolName) + .font(.system(size: 11, weight: .semibold)) + Text(app.title) + .font(.caption.weight(.medium)) + } + .foregroundStyle(Color.popSecondaryLabel) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(coverage.label) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(coverage.enabled > 0 ? app.inspectorAccentColor : Color.popTertiaryLabel) + Text(localization.string("matrix.package.coverage.percent", coverage.percent)) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + } + ProgressView(value: Double(coverage.enabled), total: Double(max(coverage.total, 1))) + .tint(app.inspectorAccentColor) + Text(packageCoverageStatusLabel(coverage)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(coverage.stubbed > 0 ? Color.popStatusWarning : Color.popTertiaryLabel) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func packageCoverageStatusLabel(_ coverage: PackageAppCoverageBreakdown) -> String { + var parts: [String] = [] + if coverage.stubbed > 0 { + parts.append(localization.string("matrix.package.coverage.stubbed", coverage.stubbed)) + } + if coverage.off > 0 { + parts.append(localization.string("matrix.package.coverage.off", coverage.off)) + } + if !parts.isEmpty { + return parts.joined(separator: " · ") + } + return localization.string("matrix.package.coverage.complete") + } + + @ViewBuilder + private func packageMachineSection(_ package: CapabilityPackage) -> some View { + let metrics = packageMachineMetrics(package) + if !metrics.isEmpty { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.machine", accent: .popSectionGreen) + LazyVGrid(columns: Self.machineMetricColumns, alignment: .leading, spacing: 8) { + ForEach(metrics) { metric in + machineMetricCard(metric) + } + } + } + } + } + + private func packageMachineMetrics(_ package: CapabilityPackage) -> [InspectorMachineMetric] { + var metrics: [InspectorMachineMetric] = [] + let snapshot = package.usageSnapshot(using: store.usageSummary, skills: store.skills) + + if let snapshot, snapshot.hasUsage { + metrics.append(InspectorMachineMetric( + id: "tokens", + title: localization.string("matrix.machine.tokens"), + value: UsageDisplayFormatter.compactTokens(snapshot.totalTokens), + tint: Color.accentColor + )) + metrics.append(InspectorMachineMetric( + id: "calls", + title: localization.string("matrix.machine.calls"), + value: UsageDisplayFormatter.compactCount(snapshot.usageEvents), + tint: Color.popLabel + )) + if let topComponent = snapshot.componentStats.first { + metrics.append(InspectorMachineMetric( + id: "top-component", + title: localization.string("matrix.machine.topComponent"), + value: topComponent.componentName, + tint: Color.popSectionGreen + )) + } + } + + if let size = package.installedSizeBytes(in: store.skills) { + metrics.append(InspectorMachineMetric( + id: "size", + title: localization.string("matrix.machine.size"), + value: Self.formatBytes(size), + tint: Color.popSecondaryLabel + )) + } + + return metrics + } + + private func packageActivationSection(_ package: CapabilityPackage) -> some View { + let matchedSkills = package.matchingInstalledSkills(in: store.skills) + let claudeTargets = package.installedSkillsRequiringEnablement(for: .claude, in: store.skills) + let codexTargets = package.installedSkillsRequiringEnablement(for: .codex, in: store.skills) + + return VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.activation", accent: .accentColor) + LazyVGrid(columns: Self.actionGridColumns, alignment: .leading, spacing: 8) { + packageActivationButton(app: .claude, package: package, targets: claudeTargets) + packageActivationButton(app: .codex, package: package, targets: codexTargets) + } + if matchedSkills.isEmpty { + Text(localization.string("matrix.package.activation.empty")) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } else if claudeTargets.isEmpty && codexTargets.isEmpty { + Text(localization.string("matrix.package.activation.complete")) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } else { + Text(localization.string("matrix.package.activation.remaining", claudeTargets.count, codexTargets.count)) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + } + } + + private func packageActivationButton(app: TargetApp, package: CapabilityPackage, targets: [Skill]) -> some View { + let inFlight = packageActivationInFlight(for: targets, app: app) + return inspectorActionButton( + titleKey: packageActivationTitleKey(for: app), + systemImage: app.symbolName, + inFlight: inFlight, + disabled: targets.isEmpty || inFlight + ) { + Task { await activatePackage(package, app: app) } + } + } + + private func packageActivationTitleKey(for app: TargetApp) -> String { + switch app { + case .claude: return "matrix.package.action.activateClaude" + case .codex: return "matrix.package.action.activateCodex" + default: return "matrix.package.action.activateApp" + } + } + + private func packageComponentsSection(_ package: CapabilityPackage) -> some View { + let usageStatsByComponentID = Dictionary( + uniqueKeysWithValues: package + .usageSnapshot(using: store.usageSummary, skills: store.skills)? + .componentStats + .map { ($0.componentID, $0) } ?? [] + ) + + return VStack(alignment: .leading, spacing: 10) { + SectionHeading(title: "matrix.inspector.section.components", accent: .popSectionPurple) + ForEach(package.componentGroupSummaries) { group in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(group.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.popLabel) + Text("\(group.installed)/\(group.total)") + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + if group.missingRequired > 0 { + Text(localization.string("matrix.package.missingRequired", group.missingRequired)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popStatusError) + } + Spacer() + } + ForEach(components(in: group.kind, package: package), id: \.displayKey) { component in + packageComponentDetailRow( + component, + package: package, + usageStat: usageStatsByComponentID[component.id] + ) + } + } + .padding(8) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + + private func packageUsageSection(_ package: CapabilityPackage) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.usage", accent: .accentColor) + usageWindowCaption + + if store.usageScanInFlight { + HStack(spacing: 8) { + ProgressView().controlSize(.mini) + LocalizedText("matrix.package.usage.scanning") + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + } + } else if let snapshot = package.usageSnapshot(using: store.usageSummary, skills: store.skills) { + if snapshot.hasUsage { + HStack(spacing: 10) { + packageUsageMetric( + titleKey: "matrix.package.usage.tokens", + value: Self.formatTokens(snapshot.totalTokens), + tint: .accentColor + ) + packageUsageMetric( + titleKey: "matrix.package.usage.calls", + value: "\(snapshot.usageEvents)", + tint: .popSectionGreen + ) + } + if let lastUsedAt = snapshot.lastUsedAt { + Text(localization.string("matrix.package.usage.lastUsed", Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + if !snapshot.dailyStats.isEmpty { + usageTrend(snapshot.dailyStats) + } + if !snapshot.componentStats.isEmpty { + packageUsageBreakdown(snapshot.componentStats) + } + } else { + Text(localization.string("matrix.package.usage.empty", snapshot.matchedSkillCount)) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + } + } else { + HStack(alignment: .center, spacing: 8) { + Text(localization.string("matrix.package.usage.notScanned")) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + Spacer(minLength: 8) + Button { + Task { await store.refreshUsageScan() } + } label: { + Label(localization.string("insights.refresh"), systemImage: "chart.bar.doc.horizontal") + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } + } + } + + private func usageTrend(_ stats: [UsageBucketStat]) -> some View { + let buckets = usageTrendBuckets(stats, window: store.usageSummary?.recent30Days) + let peak = buckets.max { lhs, rhs in + if lhs.usageEvents == rhs.usageEvents { + return lhs.dayStart < rhs.dayStart + } + return lhs.usageEvents < rhs.usageEvents + } + + return VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + LocalizedText("matrix.package.usage.trend") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + Spacer(minLength: 8) + if let peak, peak.usageEvents > 0 { + Text(localization.string( + "matrix.package.usage.peak", + UsageDisplayFormatter.compactCount(peak.usageEvents), + Self.usageDayFormatter.string(from: peak.dayStart) + )) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + } + + UsageTrendBars(buckets: buckets, tint: .accentColor) + + if let first = buckets.first?.dayStart, let last = buckets.last?.dayStart { + HStack { + Text(Self.usageDayFormatter.string(from: first)) + Spacer() + Text(Self.usageDayFormatter.string(from: last)) + } + .font(.caption2.monospacedDigit()) + .foregroundStyle(Color.popTertiaryLabel) + } + } + .padding(9) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func usageTrendBuckets(_ stats: [UsageBucketStat], window: UsageWindowSummary?) -> [UsageBucketStat] { + let indexed = Dictionary(uniqueKeysWithValues: stats.map { + (Self.usageCalendar.startOfDay(for: $0.dayStart), $0) + }) + + guard let window else { + return stats.sorted { $0.dayStart < $1.dayStart } + } + + let endDay = Self.usageCalendar.startOfDay(for: window.endedAt) + let startDay = Self.usageCalendar.date( + byAdding: .day, + value: -(max(1, window.days) - 1), + to: endDay + ) ?? endDay + + return (0.. some View { + VStack(alignment: .leading, spacing: 6) { + LocalizedText("matrix.package.usage.topComponents") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + + ForEach(stats.prefix(5)) { stat in + packageUsageComponentRow(stat) + } + } + } + + private func packageUsageComponentRow(_ stat: PackageComponentUsageStat) -> some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: stat.componentKind.usageKindSymbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(stat.installed ? Color.popStatusOK : Color.popTertiaryLabel) + .frame(width: 14) + VStack(alignment: .leading, spacing: 1) { + Text(stat.componentName) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.popLabel) + .lineLimit(1) + Text(stat.componentKind.uppercased()) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color.popTertiaryLabel) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 1) { + Text(localization.string("matrix.package.usage.componentCalls", stat.usageEvents)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popLabel) + .monospacedDigit() + Text(Self.formatTokens(stat.totalTokens)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(Color.popSecondaryLabel) + } + } + .padding(8) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func packageUsageMetric(titleKey: String, value: String, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + LocalizedText(titleKey) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + Text(value) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(tint) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.07), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + @ViewBuilder + private var usageWindowCaption: some View { + if let window = store.usageSummary?.recent30Days { + Text(localization.string("matrix.package.usage.window", window.days)) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + } + + private func components(in kind: String, package: CapabilityPackage) -> [PackageComponent] { + switch kind { + case "skill": return package.components.skills + case "cli": return package.components.cli + case "mcp": return package.components.mcp + case "agent": return package.components.agents + default: return [] + } + } + + private func packageComponentDetailRow( + _ component: PackageComponent, + package: CapabilityPackage, + usageStat: PackageComponentUsageStat? + ) -> some View { + let matchedSkill = package.matchingInstalledSkill(for: component, in: store.skills) + let versionLabel = package.componentVersionLabel(for: component, in: store.skills) + + return HStack(alignment: .top, spacing: 8) { + Image(systemName: component.inspectorKindSymbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(component.installed ? Color.popStatusOK : Color.popTertiaryLabel) + .frame(width: 14) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Text(component.name) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.popLabel) + Text(component.kind.uppercased()) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(Color.popSecondaryLabel) + } + Text(component.location ?? component.id) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 5) { + HStack(spacing: 4) { + packageComponentAppStateBadge( + component.appState(for: .claude, matching: matchedSkill), + app: .claude + ) + packageComponentAppStateBadge( + component.appState(for: .codex, matching: matchedSkill), + app: .codex + ) + } + HStack(spacing: 5) { + Text(component.status) + .font(.caption2.weight(.semibold)) + .foregroundStyle(component.installed ? Color.popStatusOK : Color.popStatusWarning) + Text(versionLabel ?? "—") + .font(.caption2.monospacedDigit().weight(.medium)) + .foregroundStyle(versionLabel == nil ? Color.popTertiaryLabel : Color.accentColor) + Text(localization.string( + "matrix.package.component.calls", + usageStat.map { UsageDisplayFormatter.compactCount($0.usageEvents) } ?? "—" + )) + .font(.caption2.monospacedDigit()) + .foregroundStyle(Color.popSecondaryLabel) + } + } + } + .padding(.vertical, 2) + } + + private func packageComponentAppStateBadge(_ state: PackageComponentAppState, app: TargetApp) -> some View { + let color = packageComponentAppStateColor(state) + return HStack(spacing: 3) { + Text(packageComponentAppShortLabel(app)) + .font(.system(size: 8.5, weight: .bold)) + Text(localization.string(packageComponentAppStateTitleKey(state))) + .font(.system(size: 9.5, weight: .semibold)) + } + .foregroundStyle(color) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(color.opacity(0.10), in: Capsule()) + .help(localization.string(packageComponentAppStateHelpKey(state), app.title)) + } + + private func packageComponentAppShortLabel(_ app: TargetApp) -> String { + switch app { + case .claude: return "CC" + case .codex: return "CDX" + default: return app.title.uppercased() + } + } + + private func packageComponentAppStateTitleKey(_ state: PackageComponentAppState) -> String { + switch state { + case .active: return "matrix.package.component.state.active" + case .stub: return "matrix.package.component.state.stub" + case .off: return "matrix.package.component.state.off" + case .unsupported: return "matrix.package.component.state.unsupported" + } + } + + private func packageComponentAppStateHelpKey(_ state: PackageComponentAppState) -> String { + switch state { + case .active: return "matrix.package.component.enabledHelp" + case .stub: return "matrix.package.component.partialHelp" + case .off: return "matrix.package.component.offHelp" + case .unsupported: return "matrix.package.component.unsupportedHelp" + } + } + + private func packageComponentAppStateColor(_ state: PackageComponentAppState) -> Color { + switch state { + case .active: return Color.popStatusOK + case .stub: return Color.popStatusWarning + case .off: return Color.popTertiaryLabel + case .unsupported: return Color.popSecondaryLabel + } + } + + private func packageConfigSection(_ package: CapabilityPackage) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.config", accent: .popSectionPurple) + ForEach(package.configSchema) { field in + HStack(spacing: 8) { + Text(field.label) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.popLabel) + if field.required { + Text(localization.string("matrix.package.component.required")) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popStatusWarning) + } + Spacer() + Text(field.secret ? localization.string("matrix.package.config.secret") : field.storage) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + } + .padding(8) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + + private func packageLocalPathsSection(_ package: CapabilityPackage) -> some View { + let matchedSkills = package.matchingInstalledSkills(in: store.skills) + return VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.paths") + if matchedSkills.isEmpty { + Text(localization.string("matrix.package.paths.empty")) + .font(.caption) + .foregroundStyle(Color.popTertiaryLabel) + } else { + ForEach(matchedSkills.prefix(8), id: \.id) { skill in + packagePathRow(skill) + } + if matchedSkills.count > 8 { + Text(localization.string("matrix.package.paths.more", matchedSkills.count - 8)) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + } + } + } + + private func packagePathRow(_ skill: Skill) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "doc.text") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(skill.markdownURL == nil ? Color.popTertiaryLabel : Color.popSecondaryLabel) + .frame(width: 14) + VStack(alignment: .leading, spacing: 2) { + Text(skill.name) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.popLabel) + Text(skill.localStoreURL.path.abbreviatingWithTilde) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + Spacer(minLength: 8) + if skill.markdownURL != nil { + Text("SKILL.md") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popStatusOK) + } + if FileManager.default.fileExists(atPath: skill.localStoreURL.path) { + Button { + NSWorkspace.shared.activateFileViewerSelecting([skill.localStoreURL]) + } label: { + Image(systemName: "folder") + .font(.system(size: 11, weight: .semibold)) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .foregroundStyle(Color.popSecondaryLabel) + .help(localization.string("matrix.row.menu.revealInFinder")) + } + } + .padding(8) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func packageVersionSection(_ package: CapabilityPackage) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.version") + VStack(alignment: .leading, spacing: 6) { + metaRow(label: localization.string("matrix.package.version.strategy"), value: package.source.updateStrategy) + if let branch = package.source.repoBranch, !branch.isEmpty { + metaRow(label: localization.string("matrix.package.version.branch"), value: branch) + } + if let installedAt = package.lifecycle?.installedAt, installedAt > 0 { + metaRow(label: localization.string("matrix.inspector.meta.installedAt"), value: Self.formatTimestamp(installedAt)) + } + if let updatedAt = package.lifecycle?.updatedAt, updatedAt > 0 { + metaRow(label: localization.string("matrix.inspector.meta.updatedAt"), value: Self.formatTimestamp(updatedAt)) + } + metaRow( + label: localization.string("matrix.package.version.hash"), + value: package.trackedContentHash.map(Self.shortHash) ?? localization.string("matrix.package.version.untracked") + ) + } + packageComponentVersionsSection(package) + } + } + + @ViewBuilder + private func packageComponentVersionsSection(_ package: CapabilityPackage) -> some View { + let summaries = package.componentVersionSummaries(in: store.skills) + if !summaries.isEmpty { + VStack(alignment: .leading, spacing: 6) { + LocalizedText("matrix.package.version.components") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + ForEach(summaries.prefix(8)) { summary in + packageComponentVersionRow(summary) + } + if summaries.count > 8 { + Text(localization.string("matrix.package.paths.more", summaries.count - 8)) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + } + .padding(.top, 4) + } + } + + private func packageComponentVersionRow(_ summary: PackageComponentVersionSummary) -> some View { + HStack(spacing: 8) { + Image(systemName: summary.kind.usageKindSymbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(summary.versionLabel == nil ? Color.popTertiaryLabel : Color.popSecondaryLabel) + .frame(width: 14) + VStack(alignment: .leading, spacing: 1) { + Text(summary.name) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.popLabel) + .lineLimit(1) + Text(summary.status) + .font(.caption2) + .foregroundStyle(Color.popSecondaryLabel) + } + Spacer(minLength: 8) + Text(summary.versionLabel ?? localization.string("matrix.package.version.untracked")) + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(summary.versionLabel == nil ? Color.popTertiaryLabel : Color.accentColor) + } + .padding(8) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func skillVersionSection(_ skill: Skill) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.version") + VStack(alignment: .leading, spacing: 6) { + if let version = skill.manifest?.semanticVersion { + metaRow( + label: localization.string("matrix.skill.manifest.version"), + value: MatrixVersionFormatter.semanticVersion(version) ?? version + ) + } + if let author = skill.manifest?.author?.trimmingCharacters(in: .whitespacesAndNewlines), !author.isEmpty { + metaRow(label: localization.string("matrix.skill.manifest.author"), value: author) + } + if let license = skill.manifest?.license?.trimmingCharacters(in: .whitespacesAndNewlines), !license.isEmpty { + metaRow(label: localization.string("matrix.skill.manifest.license"), value: license) + } + if let manifest = skill.manifest, + let requiredTools = manifest.requiredToolsLabel( + availableLabel: localization.string("matrix.skill.manifest.requires.available"), + missingLabel: localization.string("matrix.skill.manifest.requires.missing") + ) { + metaRow(label: localization.string("matrix.skill.manifest.requires"), value: requiredTools) + } + metaRow( + label: localization.string("matrix.package.version.hash"), + value: skill.contentHash.map(Self.shortHash) ?? localization.string("matrix.package.version.untracked") + ) + if let installedAt = skill.installedAt, installedAt > 0 { + metaRow(label: localization.string("matrix.inspector.meta.installedAt"), value: Self.formatTimestamp(installedAt)) + } + if let updatedAt = skill.updatedAt, updatedAt > 0 { + metaRow(label: localization.string("matrix.inspector.meta.updatedAt"), value: Self.formatTimestamp(updatedAt)) + } + if let size = skill.sizeBytes, size > 0 { + metaRow(label: localization.string("matrix.inspector.meta.size"), value: Self.formatBytes(size)) + } + if let source = skill.sourceType, !source.isEmpty { + metaRow(label: localization.string("matrix.inspector.meta.sourceType"), value: source) + } + } + if let url = skill.sourceURL { + Link(destination: url) { + Label(localization.string("matrix.inspector.meta.openSource"), systemImage: "arrow.up.right.square") + .font(.caption) + } + .buttonStyle(.plain) + } + } + } + + private func packageSyncSection(_ package: CapabilityPackage) -> some View { + let pendingUpdates = packagePendingUpdates(package) + return VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.package.sync.upstream") + HStack(spacing: 8) { + syncStatusPill(for: pendingUpdates) + if store.updatesRefreshInFlight { + HStack(spacing: 6) { + ProgressView().controlSize(.mini) + Text(localization.string("matrix.package.sync.checking")) + } + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + } else if let lastCheckedAt = store.lastUpdatesRefreshAt { + Text(localization.string("matrix.package.sync.checked", Self.relativeFormatter.localizedString(for: lastCheckedAt, relativeTo: Date()))) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + } else { + Text(localization.string("matrix.package.sync.notChecked")) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + } + } + + if pendingUpdates.isEmpty { + Text(localization.string("matrix.package.sync.clean")) + .font(.caption) + .foregroundStyle(Color.popTertiaryLabel) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(pendingUpdates.prefix(4)) { update in + packageSyncUpdateRow(update) + } + if pendingUpdates.count > 4 { + Text(localization.string("matrix.package.sync.more", pendingUpdates.count - 4)) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + } + } + } + } + + private func packageSyncStatusSection(_ package: CapabilityPackage) -> some View { + let snapshot = package.linkHealthSnapshot(using: store.linkHealth, skills: store.skills) + return VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.package.sync.status", accent: .popSectionGreen) + LazyVGrid(columns: Self.syncFactColumns, alignment: .leading, spacing: 8) { + syncFactCard( + titleKey: "matrix.package.sync.source", + value: package.source.location, + systemImage: "chevron.left.forwardslash.chevron.right", + tint: Color.accentColor + ) + syncFactCard( + titleKey: "matrix.package.sync.provider", + value: syncProviderLabel, + systemImage: syncProviderSymbol, + tint: Color.popSectionPurple + ) + syncFactCard( + titleKey: "matrix.package.sync.lastSync", + value: lastSyncLabel, + systemImage: "clock.arrow.circlepath", + tint: Color.popSecondaryLabel + ) + syncFactCard( + titleKey: "matrix.package.sync.linkHealth", + value: packageLinkHealthLabel(snapshot), + systemImage: packageLinkHealthSymbol(snapshot), + tint: packageLinkHealthTint(snapshot) + ) + } + + packageLinkHealthRows(snapshot) + } + } + + private var syncProviderLabel: String { + if let provider = SyncProvider(rawValue: store.lastSyncProvider) { + return localization.string(provider.titleKey) + } + return store.lastSyncProvider + } + + private var syncProviderSymbol: String { + SyncProvider(rawValue: store.lastSyncProvider)?.symbol ?? "arrow.triangle.2.circlepath" + } + + private var lastSyncLabel: String { + if let lastSyncAt = store.lastSyncAt { + return Self.relativeFormatter.localizedString(for: lastSyncAt, relativeTo: Date()) + } + return localization.string("sidebar.sync.never") + } + + private func packageLinkHealthLabel(_ snapshot: PackageLinkHealthSnapshot?) -> String { + guard let snapshot else { + return localization.string("matrix.package.sync.linkHealthNotScanned") + } + return localization.string( + "matrix.package.sync.linkHealthSummary", + snapshot.okCount, + snapshot.brokenCount, + snapshot.inactiveCount + ) + } + + private func packageLinkHealthSymbol(_ snapshot: PackageLinkHealthSnapshot?) -> String { + guard let snapshot else { return "checkmark.shield" } + if snapshot.brokenCount > 0 { return "exclamationmark.triangle.fill" } + if snapshot.okCount > 0 { return "link" } + return "circle.dashed" + } + + private func packageLinkHealthTint(_ snapshot: PackageLinkHealthSnapshot?) -> Color { + guard let snapshot else { return Color.popTertiaryLabel } + if snapshot.brokenCount > 0 { return Color.popStatusError } + if snapshot.okCount > 0 { return Color.popStatusOK } + return Color.popTertiaryLabel + } + + private func syncFactCard(titleKey: String, value: String, systemImage: String, tint: Color) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: systemImage) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 14) + VStack(alignment: .leading, spacing: 2) { + LocalizedText(titleKey) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + Text(value.isEmpty ? "—" : value) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Color.popLabel) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + Spacer(minLength: 4) + } + .padding(9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.07), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + @ViewBuilder + private func packageLinkHealthRows(_ snapshot: PackageLinkHealthSnapshot?) -> some View { + if let snapshot { + if snapshot.rows.isEmpty { + Text(localization.string("matrix.package.sync.linkHealthEmpty")) + .font(.caption) + .foregroundStyle(Color.popTertiaryLabel) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(snapshot.rows.prefix(4), id: \.skillId) { row in + packageLinkHealthRow(row) + } + if snapshot.rows.count > 4 { + Text(localization.string("matrix.package.paths.more", snapshot.rows.count - 4)) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } } - appsSection - if capability.kind == .skill { - deploymentSection + } + } else { + HStack(alignment: .center, spacing: 8) { + Text(localization.string("matrix.package.sync.linkHealthNotScannedDetail")) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + Spacer(minLength: 8) + Button { + Task { await refreshPackageLinkHealth() } + } label: { + if linkHealthScanInFlight { + ProgressView().controlSize(.mini) + } else { + Label(localization.string("health.empty.runScan"), systemImage: "play.circle") + } } - metadataSection + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(linkHealthScanInFlight) } - .padding(.horizontal, 18) - .padding(.vertical, 18) } - .background(Color.popCardBackground.opacity(0.72)) } - // MARK: Header - - private var header: some View { - HStack(alignment: .top, spacing: 10) { - InitialAvatarView(name: capability.name, identifier: capability.id) + private func packageLinkHealthRow(_ row: LinkHealthRow) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: row.deployment?.hasBrokenLink == true ? "exclamationmark.triangle.fill" : "link") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(row.deployment?.hasBrokenLink == true ? Color.popStatusError : Color.popStatusOK) + .frame(width: 14) VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(capability.name) - .font(.title3.weight(.semibold)) - .foregroundStyle(Color.popLabel) - .lineLimit(2) - if capability.kind != .skill { - kindChip - } - } - Text(capability.sourceLabel) - .font(.caption) + Text(row.skillName) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.popLabel) + .lineLimit(1) + Text(row.deployment?.ssotPath.abbreviatingWithTilde ?? localization.string("matrix.package.sync.noDeployment")) + .font(.system(size: 10.5, design: .monospaced)) .foregroundStyle(Color.popSecondaryLabel) .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) } - Spacer() - Button { - store.closeInspector() - } label: { - Image(systemName: "xmark") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Color.popSecondaryLabel) - .frame(width: 22, height: 22) - .background(Color.popSubtleFill, in: Circle()) + Spacer(minLength: 8) + HStack(spacing: 4) { + ForEach(sortedAppLinks(row.deployment?.appLinks ?? [:]), id: \.key) { key, link in + packageLinkStatusBadge(appKey: key, status: link.status) + } } - .buttonStyle(.plain) - .help(localization.string("matrix.inspector.close")) } + .padding(8) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - private var kindChip: some View { - HStack(spacing: 3) { - Image(systemName: capability.kind.symbol) - .font(.system(size: 9, weight: .semibold)) - Text(localization.string(capability.kind.titleKey).uppercased()) - .font(.system(size: 9.5, weight: .bold)) + private func packageLinkStatusBadge(appKey: String, status: String) -> some View { + let (label, color): (String, Color) = { + switch status.lowercased() { + case "ok": return (localization.string("matrix.inspector.linkStatus.ok"), Color.popStatusOK) + case "broken": return (localization.string("matrix.inspector.linkStatus.broken"), Color.popStatusError) + case "inactive": return (localization.string("matrix.inspector.linkStatus.inactive"), Color.popTertiaryLabel) + case "na": return (localization.string("matrix.inspector.linkStatus.na"), Color.popTertiaryLabel) + default: return (status, Color.popSecondaryLabel) + } + }() + return HStack(spacing: 3) { + Text(packageLinkAppShortLabel(for: appKey)) + .font(.system(size: 8.5, weight: .bold)) + Text(label) + .font(.system(size: 9.5, weight: .semibold)) } - .foregroundStyle(Color.accentColor) + .foregroundStyle(color) .padding(.horizontal, 5) - .padding(.vertical, 1.5) - .background(Color.accentColor.opacity(0.12), in: Capsule()) + .padding(.vertical, 2) + .background(color.opacity(0.10), in: Capsule()) } - // MARK: Sections + private func packageLinkAppShortLabel(for key: String) -> String { + switch key.lowercased() { + case "claude": return "CC" + case "codex": return "CDX" + default: return key.prefix(3).uppercased() + } + } - private var primaryDescription: String { - capability.summary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + private func syncStatusPill(for pendingUpdates: [SkillUpdateInfo]) -> some View { + let hasPending = !pendingUpdates.isEmpty + let color = hasPending ? Color.accentColor : Color.popStatusOK + let title = hasPending + ? localization.string("matrix.package.sync.pending", pendingUpdates.count) + : localization.string("matrix.package.sync.upToDate") + + return Text(title) + .font(.caption2.weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(color.opacity(0.10), in: Capsule()) } - private var summarySection: some View { - VStack(alignment: .leading, spacing: 6) { - SectionHeading(title: "matrix.inspector.section.summary") - Text(primaryDescription) - .font(.callout) - .foregroundStyle(Color.popLabel) - .textSelection(.enabled) + private func packageSyncUpdateRow(_ update: SkillUpdateInfo) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "arrow.down.circle") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 14) + VStack(alignment: .leading, spacing: 2) { + Text(update.name) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.popLabel) + Text(update.currentHash.map { "\(Self.shortHash($0)) -> \(Self.shortHash(update.remoteHash))" } ?? localization.string("updates.row.firstSync")) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(Color.popSecondaryLabel) + } + Spacer(minLength: 8) + } + .padding(8) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func packageMetadataSection(_ package: CapabilityPackage) -> some View { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.meta") + VStack(alignment: .leading, spacing: 6) { + metaRow(label: localization.string("matrix.inspector.meta.directory"), value: package.source.location) + metaRow(label: localization.string("matrix.inspector.meta.sourceType"), value: package.source.kind) + metaRow(label: localization.string("matrix.inspector.meta.packageType"), value: package.typeLabel) + if let installedAt = package.lifecycle?.installedAt, installedAt > 0 { + metaRow(label: localization.string("matrix.inspector.meta.installedAt"), value: Self.formatTimestamp(installedAt)) + } + if let updatedAt = package.lifecycle?.updatedAt, updatedAt > 0 { + metaRow(label: localization.string("matrix.inspector.meta.updatedAt"), value: Self.formatTimestamp(updatedAt)) + } + } + if let url = package.sourceURL { + Link(destination: url) { + Label(localization.string("matrix.inspector.meta.openSource"), systemImage: "arrow.up.right.square") + .font(.caption) + } + .buttonStyle(.plain) + } } } + private func packagePill(_ title: String, color: Color) -> some View { + Text(title) + .font(.caption2.weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(color.opacity(0.10), in: Capsule()) + } + private func triggerSection(scenarios: [String]) -> some View { VStack(alignment: .leading, spacing: 6) { SectionHeading(title: "matrix.inspector.section.triggers", accent: .accentColor) @@ -114,6 +1862,79 @@ struct InspectorPane: View { } } + @ViewBuilder + private func skillBundleSection(_ skill: Skill) -> some View { + let packages = containingPackages(for: skill) + if !packages.isEmpty { + VStack(alignment: .leading, spacing: 8) { + SectionHeading(title: "matrix.inspector.section.bundle", accent: .popSectionPurple) + ForEach(packages, id: \.id) { package in + skillBundleCard(package, skill: skill) + } + } + } + } + + private func skillBundleCard(_ package: CapabilityPackage, skill: Skill) -> some View { + let companions = package.companionInstalledSkills(for: skill, in: store.skills) + return VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 8) { + PackageAvatar(name: package.name, identifier: package.id, size: 24) + VStack(alignment: .leading, spacing: 2) { + Text(package.name) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.popLabel) + .lineLimit(1) + Text(PackageComponentCompositionFormatter.composition(for: package, localization: localization)) + .font(.caption2) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + } + Spacer(minLength: 8) + Button { + store.selectCapability(MatrixCapability.packageCapabilityID(for: package.id)) + } label: { + Image(systemName: "arrow.right.circle") + .font(.system(size: 13, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + .help(localization.string("matrix.skill.bundle.open")) + } + + if !companions.isEmpty { + Text(localization.string("matrix.skill.bundle.companions", companions.count)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.popSecondaryLabel) + + LazyVGrid(columns: Self.companionGridColumns, alignment: .leading, spacing: 5) { + ForEach(companions.prefix(8), id: \.id) { companion in + Button { + store.selectCapability(MatrixCapability.skillCapabilityID(for: companion.id)) + } label: { + Text(companion.name) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.popControlFill, in: Capsule()) + } + .buttonStyle(.plain) + .foregroundStyle(Color.popSecondaryLabel) + .help(localization.string("matrix.skill.bundle.openCompanion", companion.name)) + } + if companions.count > 8 { + Text(localization.string("matrix.package.paths.more", companions.count - 8)) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + } + } + } + } + .padding(9) + .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + private var appsSection: some View { VStack(alignment: .leading, spacing: 8) { SectionHeading(title: "matrix.inspector.section.apps") @@ -159,9 +1980,9 @@ struct InspectorPane: View { .disabled(pending || !capability.isToggleable) } - private var deploymentSection: some View { + private func skillPathsSection(_ skill: Skill) -> some View { VStack(alignment: .leading, spacing: 10) { - SectionHeading(title: "matrix.inspector.section.deployment") + SectionHeading(title: "matrix.inspector.section.paths") if let deployment = capability.deployment { VStack(alignment: .leading, spacing: 6) { deploymentRow( @@ -177,6 +1998,13 @@ struct InspectorPane: View { ) } } + if let markdownURL = skill.markdownURL { + deploymentRow( + title: localization.string("matrix.skill.paths.readme"), + path: markdownURL.path, + status: "ok" + ) + } HStack(spacing: 4) { Image(systemName: "link") .font(.system(size: 10, weight: .semibold)) @@ -185,8 +2013,22 @@ struct InspectorPane: View { } .foregroundStyle(Color.popTertiaryLabel) } else { + VStack(alignment: .leading, spacing: 6) { + deploymentRow( + title: localization.string("matrix.inspector.deployment.ssot"), + path: skill.localStoreURL.path, + status: FileManager.default.fileExists(atPath: skill.localStoreURL.path) ? "ok" : nil + ) + if let markdownURL = skill.markdownURL { + deploymentRow( + title: localization.string("matrix.skill.paths.readme"), + path: markdownURL.path, + status: "ok" + ) + } + } Text(localization.string("matrix.inspector.deployment.empty")) - .font(.caption) + .font(.caption2) .foregroundStyle(Color.popTertiaryLabel) } } @@ -230,11 +2072,34 @@ struct InspectorPane: View { if let status { linkStatusBadge(status) } + if let url = revealableURL(for: path) { + Button { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } label: { + Image(systemName: "folder") + .font(.system(size: 11, weight: .semibold)) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .foregroundStyle(Color.popSecondaryLabel) + .help(localization.string("matrix.row.menu.revealInFinder")) + } } .padding(8) .background(Color.popSubtleFill, in: RoundedRectangle(cornerRadius: 6)) } + private func revealableURL(for path: String) -> URL? { + guard !path.isEmpty, path != "—" else { + return nil + } + let expanded = (path as NSString).expandingTildeInPath + guard FileManager.default.fileExists(atPath: expanded) else { + return nil + } + return URL(fileURLWithPath: expanded) + } + private func linkStatusBadge(_ status: String) -> some View { let (label, color): (String, Color) = { switch status.lowercased() { @@ -312,12 +2177,98 @@ struct InspectorPane: View { ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) } + private static func formatTokens(_ value: Int64) -> String { + if value < 1_000 { + return decimalFormatter.string(from: NSNumber(value: value)) ?? "0" + } + if value < 1_000_000 { + return String(format: "%.1fK", Double(value) / 1_000.0) + } + if value < 1_000_000_000 { + return String(format: "%.1fM", Double(value) / 1_000_000.0) + } + return String(format: "%.2fB", Double(value) / 1_000_000_000.0) + } + + private static func shortHash(_ hash: String) -> String { + String(hash.trimmingCharacters(in: .whitespacesAndNewlines).prefix(8)) + } + + private static let decimalFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.groupingSeparator = "," + return formatter + }() + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() + + private static let usageDayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "M-d" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + private static let usageCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + return calendar + }() + + private static let actionGridColumns: [GridItem] = [ + GridItem(.adaptive(minimum: 132), spacing: 8, alignment: .leading) + ] + + private static let machineMetricColumns: [GridItem] = [ + GridItem(.adaptive(minimum: 96), spacing: 8, alignment: .leading) + ] + + private static let syncFactColumns: [GridItem] = [ + GridItem(.adaptive(minimum: 132), spacing: 8, alignment: .leading) + ] + + private static let companionGridColumns: [GridItem] = [ + GridItem(.adaptive(minimum: 84), spacing: 5, alignment: .leading) + ] + + private func firstRevealableSkillURL(for package: CapabilityPackage) -> URL? { + package.matchingInstalledSkills(in: store.skills) + .first { FileManager.default.fileExists(atPath: $0.localStoreURL.path) }? + .localStoreURL + } + + private func readmeSkill(for package: CapabilityPackage) -> Skill? { + package.matchingInstalledSkills(in: store.skills) + .first { $0.markdownURL != nil } + } + + private func packagePendingUpdates(_ package: CapabilityPackage) -> [SkillUpdateInfo] { + store.updates.filter { package.matchingSkillComponent(for: $0) != nil } + } + + private func containingPackages(for skill: Skill) -> [CapabilityPackage] { + store.compositePackages.filter { $0.containsSkill(skill) } + } + // MARK: Toggle helpers private func toggleKey(_ app: TargetApp) -> String { MatrixCapability.toggleKey(capabilityID: capability.id, app: app) } + private func packageToggleKey(skillID: String, app: TargetApp) -> String { + MatrixCapability.toggleKey(capabilityID: MatrixCapability.skillCapabilityID(for: skillID), app: app) + } + + private func packageActivationInFlight(for targets: [Skill], app: TargetApp) -> Bool { + targets.contains { store.pendingToggles.contains(packageToggleKey(skillID: $0.id, app: app)) } + } + @MainActor private func toggle(app: TargetApp, enabled: Bool) async { guard let skillID = capability.underlyingSkillID else { return } @@ -335,4 +2286,183 @@ struct InspectorPane: View { store.errorMessage = error.localizedDescription } } + + @MainActor + private func activatePackage(_ package: CapabilityPackage, app: TargetApp) async { + let targets = package.installedSkillsRequiringEnablement(for: app, in: store.skills) + guard !targets.isEmpty else { return } + + let keys = Set(targets.map { packageToggleKey(skillID: $0.id, app: app) }) + guard keys.isDisjoint(with: store.pendingToggles) else { return } + store.pendingToggles.formUnion(keys) + defer { store.pendingToggles.subtract(keys) } + + var firstError: Error? + for target in targets { + do { + try await store.client.toggle(skillID: target.id, app: app, enabled: true) + if let idx = store.skills.firstIndex(where: { $0.id == target.id }) { + store.skills[idx].apps.setEnabled(true, for: app) + } + } catch { + if firstError == nil { + firstError = error + } + } + } + + if let firstError { + store.errorMessage = firstError.localizedDescription + } + } + + @MainActor + private func refreshPackageLinkHealth() async { + guard !linkHealthScanInFlight else { return } + linkHealthScanInFlight = true + defer { linkHealthScanInFlight = false } + + do { + store.linkHealth = try await store.client.linkHealth() + } catch { + store.errorMessage = error.localizedDescription + } + } +} + +private extension String { + var abbreviatingWithTilde: String { + (self as NSString).abbreviatingWithTildeInPath + } +} + +private struct InspectorHeaderChip: Identifiable { + let id: String + let title: String + let tint: Color +} + +private struct InspectorMachineMetric: Identifiable { + let id: String + let title: String + let value: String + let tint: Color +} + +private struct InspectorSourceFact: Identifiable { + let id: String + let title: String + let value: String + let icon: String + var url: URL? = nil +} + +private struct UsageTrendBars: View { + let buckets: [UsageBucketStat] + let tint: Color + + private var maxEvents: Int { + max(buckets.map(\.usageEvents).max() ?? 0, 1) + } + + var body: some View { + HStack(alignment: .bottom, spacing: 2) { + ForEach(buckets) { bucket in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(bucket.usageEvents > 0 ? tint.opacity(0.72) : Color.popControlFill) + .frame(height: barHeight(for: bucket)) + .frame(maxWidth: .infinity, alignment: .bottom) + .help("\(bucket.usageEvents)") + } + } + .frame(height: 42, alignment: .bottom) + } + + private func barHeight(for bucket: UsageBucketStat) -> CGFloat { + guard bucket.usageEvents > 0 else { + return 4 + } + let ratio = CGFloat(bucket.usageEvents) / CGFloat(maxEvents) + return max(7, ratio * 42) + } +} + +private enum InspectorTab: String, CaseIterable, Identifiable { + case overview + case readme + case usage + case version + case paths + case sync + case metadata + + var id: String { rawValue } + + var titleKey: String { + switch self { + case .overview: "matrix.inspector.tab.overview" + case .readme: "matrix.inspector.tab.readme" + case .usage: "matrix.inspector.tab.usage" + case .version: "matrix.inspector.tab.version" + case .paths: "matrix.inspector.tab.paths" + case .sync: "matrix.inspector.tab.sync" + case .metadata: "matrix.inspector.tab.metadata" + } + } +} + +private extension CapabilityPackageHealth { + var inspectorTitleKey: String { + switch self { + case .active: "matrix.package.health.active" + case .partial: "matrix.package.health.partial" + case .inactive: "matrix.package.health.inactive" + case .blocked: "matrix.package.health.blocked" + } + } + + var inspectorColor: Color { + switch self { + case .active: Color.popStatusOK + case .partial: Color.popStatusWarning + case .inactive: Color.popTertiaryLabel + case .blocked: Color.popStatusError + } + } +} + +private extension PackageComponent { + var inspectorKindSymbol: String { + switch kind.lowercased() { + case "skill": "square.grid.3x3.fill" + case "agent": "person.crop.square" + case "cli": "terminal" + case "mcp": "rectangle.connected.to.line.below" + default: "circle.grid.2x2" + } + } +} + +private extension String { + var usageKindSymbol: String { + switch lowercased() { + case "skill": "square.grid.3x3.fill" + case "agent": "person.crop.square" + case "cli": "terminal" + case "mcp": "rectangle.connected.to.line.below" + default: "circle.grid.2x2" + } + } +} + +private extension TargetApp { + var inspectorAccentColor: Color { + switch self { + case .claude: .orange + case .codex: .green + case .gemini: .blue + case .opencode: .indigo + case .hermes: .purple + } + } } diff --git a/swift-app/Sources/Popskill/Views/LinkHealthView.swift b/swift-app/Sources/Popskill/Views/LinkHealthView.swift index 4259df7..bd65f42 100644 --- a/swift-app/Sources/Popskill/Views/LinkHealthView.swift +++ b/swift-app/Sources/Popskill/Views/LinkHealthView.swift @@ -4,6 +4,7 @@ import SwiftUI /// summary + a flat table of every row. A row is just "skillName · status /// across N apps". Clicking a row jumps to the matrix and opens the inspector /// so the user can read the SSOT paths in context. +@MainActor struct LinkHealthView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization diff --git a/swift-app/Sources/Popskill/Views/MatrixBadges.swift b/swift-app/Sources/Popskill/Views/MatrixBadges.swift new file mode 100644 index 0000000..dd57310 --- /dev/null +++ b/swift-app/Sources/Popskill/Views/MatrixBadges.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct MatrixBrokenLinkBadge: View { + @Environment(\.popskillLocalization) private var localization + + var body: some View { + Label(localization.string("matrix.row.brokenLinkBadge"), systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 9.5, weight: .semibold)) + .labelStyle(.titleAndIcon) + .foregroundStyle(Color.popStatusError) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.popStatusError.opacity(0.13), in: Capsule()) + .fixedSize(horizontal: true, vertical: false) + .help(localization.string("matrix.row.brokenLinkHelp")) + } +} diff --git a/swift-app/Sources/Popskill/Views/MatrixPackageRow.swift b/swift-app/Sources/Popskill/Views/MatrixPackageRow.swift new file mode 100644 index 0000000..1e43809 --- /dev/null +++ b/swift-app/Sources/Popskill/Views/MatrixPackageRow.swift @@ -0,0 +1,559 @@ +import AppKit +import SwiftUI + +/// Composite package row in the capability matrix. The row is read-only at the +/// package level for v1.1, but it exposes the component tree inline so Bundle +/// coverage is visible without opening a detail page. +@MainActor +struct MatrixPackageRow: View { + let capability: MatrixCapability + @Bindable var store: PopskillStore + let usageIndex: MatrixUsageIndex + @Environment(\.popskillLocalization) private var localization + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isHovering = false + + private var package: CapabilityPackage? { capability.package } + + private var isSelected: Bool { + store.selectedSkillID == capability.id + } + + private var isCollapsed: Bool { + guard let package else { return true } + return store.collapsedPackageIDs.contains(package.id) + } + + var body: some View { + VStack(spacing: 0) { + packageHeader + if let package, !isCollapsed { + let components = package.components.all + ForEach(Array(components.enumerated()), id: \.element.displayKey) { index, component in + MatrixPackageComponentRow( + component: component, + packageID: package.id, + treePrefix: PackageComponentTreePrefix.value(index: index, count: components.count), + store: store, + usageIndex: usageIndex + ) + Divider().opacity(0.28) + } + } + } + } + + private var packageHeader: some View { + HStack(spacing: 0) { + capabilityCell + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 14) + .padding(.vertical, 7) + + coverageCell(for: .claude) + .frame(width: MatrixTableLayout.appColumnWidth) + coverageCell(for: .codex) + .frame(width: MatrixTableLayout.appColumnWidth) + + sourceCell + .frame(width: MatrixTableLayout.sourceColumnWidth, alignment: .leading) + versionCell + .frame(width: MatrixTableLayout.versionColumnWidth, alignment: .leading) + + tokensCell + .frame(width: MatrixTableLayout.tokensColumnWidth, alignment: .trailing) + callsCell + .frame(width: MatrixTableLayout.callsColumnWidth, alignment: .trailing) + + actionCell + .frame(width: MatrixTableLayout.actionColumnWidth) + } + .padding(.trailing, 4) + .contentShape(Rectangle()) + .background(rowBackground) + .overlay(alignment: .leading) { + if isSelected { + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(Color.accentColor) + .frame(width: 3) + .padding(.vertical, 6) + } + } + .onTapGesture { + store.selectCapability(capability.id) + } + .onHover { isHovering = $0 } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.14), value: isHovering) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.16), value: isSelected) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(capability.name)) + .accessibilityHint(Text(capability.summary ?? localization.string("matrix.row.noSummary"))) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private var capabilityCell: some View { + HStack(alignment: .center, spacing: 10) { + Button { + if let package { + store.togglePackageExpansion(package.id) + } + } label: { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color.popSecondaryLabel) + .frame(width: 14, height: 22) + } + .buttonStyle(.plain) + .help(localization.string(isCollapsed ? "matrix.package.expand" : "matrix.package.collapse")) + + PackageAvatar(name: capability.name, identifier: capability.id, size: 26) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(capability.name) + .font(.system(size: 12.8, weight: .semibold)) + .foregroundStyle(Color.popLabel) + .lineLimit(1) + kindBadge + if let package { + healthBadge(package.health) + } + if capability.hasBrokenLinks(in: store.skills) { + MatrixBrokenLinkBadge() + } + if store.hasPendingUpdate(for: capability) { + Text(localization.string("matrix.row.updateBadge")) + .font(.system(size: 9.5, weight: .semibold)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.accentColor.opacity(0.16), in: Capsule()) + .foregroundStyle(Color.accentColor) + } + } + Text(packageSubtitle) + .font(.system(size: 11.2)) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + } + } + } + + private var kindBadge: some View { + HStack(spacing: 2) { + Image(systemName: capability.kind.symbol) + .font(.system(size: 8, weight: .semibold)) + Text(localization.string(capability.kind.titleKey).uppercased()) + .font(.system(size: 9, weight: .bold)) + } + .foregroundStyle(Color.popSectionPurple) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.popSectionPurple.opacity(0.12), in: Capsule()) + } + + private func healthBadge(_ health: CapabilityPackageHealth) -> some View { + let color = health.badgeColor + return Text(localization.string(health.titleKey)) + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(color) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background(color.opacity(0.12), in: Capsule()) + } + + private var packageSubtitle: String { + guard let package else { return capability.summary ?? localization.string("matrix.row.noSummary") } + return PackageComponentCompositionFormatter.summary(for: package, localization: localization) + } + + private func coverageCell(for app: TargetApp) -> some View { + let coverage = capability.appCoverage[app] ?? CapabilityAppCoverage(enabled: 0, total: 0) + return HStack { + Spacer(minLength: 0) + HStack(spacing: 4) { + Image(systemName: app.symbolName) + .font(.system(size: 9.5, weight: .semibold)) + Text(coverage.label) + .font(.system(size: 10.8, weight: .semibold).monospacedDigit()) + } + .foregroundStyle(coverage.enabled > 0 ? app.bundleAccentColor : Color.popTertiaryLabel) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + coverage.enabled > 0 ? app.bundleAccentColor.opacity(0.10) : Color.popControlFill, + in: Capsule() + ) + .overlay( + Capsule().strokeBorder( + coverage.enabled > 0 ? app.bundleAccentColor.opacity(0.26) : Color.popControlStroke, + lineWidth: 0.7 + ) + ) + .help(localization.string("matrix.package.coverageHelp", app.title, coverage.enabled, coverage.total)) + Spacer(minLength: 0) + } + } + + private var sourceCell: some View { + HStack(spacing: 6) { + Image(systemName: "shippingbox") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(Color.popSecondaryLabel) + Text(capability.sourceLabel) + .font(.system(size: 11)) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + .truncationMode(.middle) + } + } + + private var usageSnapshot: PackageUsageSnapshot? { + usageIndex.packageSnapshot(for: package?.id) + } + + private var versionCell: some View { + MatrixVersionValueCell(value: MatrixVersionFormatter.value( + contentHash: package?.trackedContentHash, + updatedAt: package?.lifecycle?.updatedAt + )) + } + + private var tokensCell: some View { + MatrixUsageValueCell(value: usageText { snapshot in + UsageDisplayFormatter.compactTokens(snapshot.totalTokens) + }) + } + + private var callsCell: some View { + MatrixUsageValueCell(value: usageText { snapshot in + UsageDisplayFormatter.compactCount(snapshot.usageEvents) + }) + } + + private func usageText(_ format: (PackageUsageSnapshot) -> String) -> String? { + guard let snapshot = usageSnapshot else { + return nil + } + return snapshot.hasUsage ? format(snapshot) : "0" + } + + private var actionCell: some View { + Menu { + Button { + store.selectCapability(capability.id) + } label: { + Label(localization.string("matrix.row.menu.inspect"), systemImage: "sidebar.right") + } + if let url = capability.sourceURL { + Link(destination: url) { + Label(localization.string("matrix.row.menu.openSource"), systemImage: "arrow.up.right.square") + } + } + if let package { + Button { + store.togglePackageExpansion(package.id) + } label: { + Label( + localization.string(isCollapsed ? "matrix.package.expand" : "matrix.package.collapse"), + systemImage: isCollapsed ? "chevron.down" : "chevron.up" + ) + } + if let url = revealableSkillURL(for: package) { + Button { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } label: { + Label(localization.string("matrix.row.menu.revealInFinder"), systemImage: "folder") + } + } + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.popSecondaryLabel) + .frame(width: 28, height: 22) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(localization.string("matrix.row.menu.help")) + } + + private func revealableSkillURL(for package: CapabilityPackage) -> URL? { + package.matchingInstalledSkills(in: store.skills) + .first { FileManager.default.fileExists(atPath: $0.localStoreURL.path) }? + .localStoreURL + } + + private var rowBackground: some View { + Group { + if isSelected { + Color.popSelectedRowFill + } else if isHovering { + Color.popSurfaceHover + } else { + Color.popSectionPurple.opacity(0.035) + } + } + } +} + +@MainActor +private struct MatrixPackageComponentRow: View { + let component: PackageComponent + let packageID: String + let treePrefix: String + @Bindable var store: PopskillStore + let usageIndex: MatrixUsageIndex + @Environment(\.popskillLocalization) private var localization + + private var matchingSkill: Skill? { + store.skill(for: component) + } + + var body: some View { + HStack(spacing: 0) { + componentCell + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 44) + .padding(.vertical, 5) + + appStateCell(for: .claude) + .frame(width: MatrixTableLayout.appColumnWidth) + appStateCell(for: .codex) + .frame(width: MatrixTableLayout.appColumnWidth) + + sourceCell + .frame(width: MatrixTableLayout.sourceColumnWidth, alignment: .leading) + versionCell + .frame(width: MatrixTableLayout.versionColumnWidth, alignment: .leading) + + tokensCell + .frame(width: MatrixTableLayout.tokensColumnWidth, alignment: .trailing) + callsCell + .frame(width: MatrixTableLayout.callsColumnWidth, alignment: .trailing) + + Spacer().frame(width: MatrixTableLayout.actionColumnWidth) + } + .padding(.trailing, 4) + .contentShape(Rectangle()) + .onTapGesture { + if let skill = matchingSkill { + store.selectSkill(skill.id) + } + } + .background(Color.popCardBackground.opacity(0.14)) + } + + private var componentCell: some View { + HStack(spacing: 9) { + Text(componentTreePrefix) + .font(.system(size: 11.5, weight: .medium, design: .monospaced)) + .foregroundStyle(Color.popTertiaryLabel) + .frame(width: 18, alignment: .leading) + + Image(systemName: component.kindSymbol) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(component.installed ? Color.popSecondaryLabel : Color.popTertiaryLabel) + .frame(width: 14) + + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(component.name) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.popLabel) + .lineLimit(1) + Text(component.kind.uppercased()) + .font(.system(size: 8.5, weight: .bold)) + .foregroundStyle(Color.popSecondaryLabel) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.popControlFill, in: Capsule()) + if let requirement = requirementBadge { + Text(localization.string(requirement.key)) + .font(.system(size: 8.5, weight: .semibold)) + .foregroundStyle(requirement.color) + } + if matchingSkill?.hasBrokenLink == true { + MatrixBrokenLinkBadge() + } + } + Text(component.status) + .font(.system(size: 10.2)) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + } + } + } + + private var componentTreePrefix: String { + treePrefix + } + + private var requirementBadge: (key: String, color: Color)? { + if component.required && !component.installed { + return ("matrix.package.component.required", Color.popStatusWarning) + } + if !component.required { + return ("matrix.package.component.optional", Color.popTertiaryLabel) + } + return nil + } + + private func appStateCell(for app: TargetApp) -> some View { + let indicator = component.indicator(for: app, matching: matchingSkill) + return HStack { + Spacer(minLength: 0) + Text(indicator.symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(indicator.color) + .frame(width: 26, height: 22) + .help(localization.string(indicator.helpKey, app.title)) + Spacer(minLength: 0) + } + } + + private var sourceCell: some View { + Text(component.location ?? component.id) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + .truncationMode(.middle) + } + + private var usageStat: PackageComponentUsageStat? { + usageIndex.packageComponentStat(packageID: packageID, componentID: component.id) + } + + private var versionCell: some View { + MatrixVersionValueCell( + value: MatrixVersionFormatter.value( + manifestVersion: matchingSkill?.manifest?.semanticVersion, + contentHash: matchingSkill?.contentHash, + updatedAt: matchingSkill?.updatedAt + ), + isSubtle: true + ) + } + + private var tokensCell: some View { + MatrixUsageValueCell(value: usageText { stat in + UsageDisplayFormatter.compactTokens(stat.totalTokens) + }, isSubtle: true) + } + + private var callsCell: some View { + MatrixUsageValueCell(value: usageText { stat in + UsageDisplayFormatter.compactCount(stat.usageEvents) + }, isSubtle: true) + } + + private func usageText(_ format: (PackageComponentUsageStat) -> String) -> String? { + guard usageIndex.hasSummary else { + return nil + } + guard let usageStat else { + return "0" + } + return usageStat.usageEvents > 0 || usageStat.totalTokens > 0 ? format(usageStat) : "0" + } +} + +enum PackageComponentTreePrefix { + static func value(index: Int, count: Int) -> String { + guard count > 0, index == count - 1 else { + return "├─" + } + return "└─" + } +} + +private enum ComponentAppIndicator { + case enabled + case partial + case off + + var symbol: String { + switch self { + case .enabled: "●" + case .partial: "◐" + case .off: "—" + } + } + + var color: Color { + switch self { + case .enabled: Color.popStatusOK + case .partial: Color.popStatusWarning + case .off: Color.popTertiaryLabel + } + } + + var helpKey: String { + switch self { + case .enabled: "matrix.package.component.enabledHelp" + case .partial: "matrix.package.component.partialHelp" + case .off: "matrix.package.component.offHelp" + } + } +} + +private extension PackageComponent { + var kindSymbol: String { + switch kind.lowercased() { + case "skill": "square.grid.3x3.fill" + case "agent": "person.crop.square" + case "cli": "terminal" + case "mcp": "rectangle.connected.to.line.below" + default: "circle.grid.2x2" + } + } + + func indicator(for app: TargetApp, matching skill: Skill?) -> ComponentAppIndicator { + switch kind.lowercased() { + case "skill": + if let skill { + return skill.apps.isEnabled(app) ? .enabled : .off + } + return installed ? .enabled : (status.lowercased() == "stub" ? .partial : .off) + case "agent": + if installed && app == .claude { return .enabled } + return status.lowercased() == "stub" && app == .claude ? .partial : .off + case "cli", "mcp": + return installed && (app == .claude || app == .codex) ? .enabled : .off + default: + return .off + } + } +} + +private extension CapabilityPackageHealth { + var titleKey: String { + switch self { + case .active: "matrix.package.health.active" + case .partial: "matrix.package.health.partial" + case .inactive: "matrix.package.health.inactive" + case .blocked: "matrix.package.health.blocked" + } + } + + var badgeColor: Color { + switch self { + case .active: Color.popStatusOK + case .partial: Color.popStatusWarning + case .inactive: Color.popTertiaryLabel + case .blocked: Color.popStatusError + } + } +} + +private extension TargetApp { + var bundleAccentColor: Color { + switch self { + case .claude: .orange + case .codex: .green + case .gemini: .blue + case .opencode: .indigo + case .hermes: .purple + } + } +} diff --git a/swift-app/Sources/Popskill/Views/MatrixRow.swift b/swift-app/Sources/Popskill/Views/MatrixRow.swift index e5aac3b..25a0b57 100644 --- a/swift-app/Sources/Popskill/Views/MatrixRow.swift +++ b/swift-app/Sources/Popskill/Views/MatrixRow.swift @@ -1,14 +1,16 @@ import SwiftUI /// One capability row inside the matrix. Layout mirrors `matrixColumnHeader` -/// in `MatrixView.swift`: capability column (flexible), Claude toggle -/// (100pt), Codex toggle (100pt), source label (220pt), action menu (56pt). +/// in `MatrixView.swift`: capability column (flexible), tool coverage, +/// source, version identity, usage metrics, and action menu. /// Renders Skill / Agent / CLI / MCP / Config via the unified /// `MatrixCapability` model; non-toggleable kinds (anything but skill) show /// a read-only "on" icon instead of the interactive switch. +@MainActor struct MatrixRow: View { let capability: MatrixCapability @Bindable var store: PopskillStore + let usageIndex: MatrixUsageIndex @Environment(\.popskillLocalization) private var localization @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var isHovering = false @@ -26,18 +28,25 @@ struct MatrixRow: View { capabilityCell .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 14) - .padding(.vertical, 8) + .padding(.vertical, 6) appToggleCell(for: .claude) - .frame(width: 100) + .frame(width: MatrixTableLayout.appColumnWidth) appToggleCell(for: .codex) - .frame(width: 100) + .frame(width: MatrixTableLayout.appColumnWidth) sourceCell - .frame(width: 220, alignment: .leading) + .frame(width: MatrixTableLayout.sourceColumnWidth, alignment: .leading) + versionCell + .frame(width: MatrixTableLayout.versionColumnWidth, alignment: .leading) + + tokensCell + .frame(width: MatrixTableLayout.tokensColumnWidth, alignment: .trailing) + callsCell + .frame(width: MatrixTableLayout.callsColumnWidth, alignment: .trailing) actionCell - .frame(width: 56) + .frame(width: MatrixTableLayout.actionColumnWidth) } .padding(.trailing, 4) .contentShape(Rectangle()) @@ -66,15 +75,17 @@ struct MatrixRow: View { private var capabilityCell: some View { HStack(alignment: .center, spacing: 10) { - InitialAvatarView(name: capability.name, identifier: capability.id) - .frame(width: 28, height: 28) + InitialAvatarView(name: capability.name, identifier: capability.id, size: 24) VStack(alignment: .leading, spacing: 1) { HStack(spacing: 6) { Text(capability.name) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(Color.popLabel) .lineLimit(1) kindBadge + if capability.hasBrokenLinks(in: store.skills) { + MatrixBrokenLinkBadge() + } if hasUpdate { Text(localization.string("matrix.row.updateBadge")) .font(.system(size: 9.5, weight: .semibold)) @@ -128,7 +139,7 @@ struct MatrixRow: View { onChange: { newValue in Task { await toggle(app: app, enabled: newValue) } }, - size: 26 + size: 22 ) } else { readOnlyAppBadge(app: app, isOn: capability.apps.isEnabled(app)) @@ -181,16 +192,50 @@ struct MatrixRow: View { private var sourceCell: some View { HStack(spacing: 6) { Image(systemName: sourceSymbol) - .font(.system(size: 11, weight: .semibold)) + .font(.system(size: 10.5, weight: .semibold)) .foregroundStyle(Color.popSecondaryLabel) Text(capability.sourceLabel) - .font(.system(size: 11.5)) + .font(.system(size: 11)) .foregroundStyle(Color.popSecondaryLabel) .lineLimit(1) .truncationMode(.middle) } } + private var usageSnapshot: SkillUsageSnapshot? { + usageIndex.skillSnapshot(for: capability.underlyingSkillID) + } + + private var versionCell: some View { + let skill = capability.underlyingSkillID.flatMap { skillID in + store.skills.first { $0.id == skillID } + } + return MatrixVersionValueCell(value: MatrixVersionFormatter.value( + manifestVersion: skill?.manifest?.semanticVersion, + contentHash: skill?.contentHash, + updatedAt: capability.updatedAt + )) + } + + private var tokensCell: some View { + MatrixUsageValueCell(value: usageText { snapshot in + UsageDisplayFormatter.compactTokens(snapshot.totalTokens) + }) + } + + private var callsCell: some View { + MatrixUsageValueCell(value: usageText { snapshot in + UsageDisplayFormatter.compactCount(snapshot.usageEvents) + }) + } + + private func usageText(_ format: (SkillUsageSnapshot) -> String) -> String? { + guard let snapshot = usageSnapshot else { + return nil + } + return snapshot.hasUsage ? format(snapshot) : "0" + } + private var sourceSymbol: String { switch (capability.sourceType ?? "").lowercased() { case "github": return "chevron.left.forwardslash.chevron.right" @@ -253,11 +298,41 @@ struct MatrixRow: View { } } +struct MatrixVersionValueCell: View { + let value: String? + var isSubtle = false + + var body: some View { + Text(value ?? "—") + .font(.system(size: isSubtle ? 10.3 : 10.8, weight: .medium, design: .monospaced)) + .foregroundStyle(value == nil ? Color.popTertiaryLabel : (isSubtle ? Color.popSecondaryLabel : Color.popLabel)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 8) + } +} + +struct MatrixUsageValueCell: View { + let value: String? + var isSubtle = false + + var body: some View { + Text(value ?? "—") + .font(.system(size: isSubtle ? 10.5 : 11, weight: isSubtle ? .regular : .medium).monospacedDigit()) + .foregroundStyle(value == nil ? Color.popTertiaryLabel : (isSubtle ? Color.popSecondaryLabel : Color.popLabel)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.trailing, 8) + } +} + /// Sticky header above each repo bucket. Clicking the chevron collapses / /// expands the bucket. The right side shows aggregate "%d enabled on Claude /// / Codex" so users can see coverage without scanning every row. v0.4 /// renders the same header for any capability kind — the kind-level banner /// in MatrixView provides the kind context. +@MainActor struct MatrixGroupHeader: View { let group: MatrixGroup @Bindable var store: PopskillStore @@ -301,8 +376,8 @@ struct MatrixGroupHeader: View { coverageChip(symbol: "chevron.left.forwardslash.chevron.right", label: "Codex", enabled: codexOn, total: group.capabilities.count) } .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(Color.popSurface.opacity(0.52)) + .padding(.vertical, 6) + .background(Color.popSurface.opacity(0.36)) .overlay(alignment: .bottom) { Rectangle() .fill(Color.popSeparator) diff --git a/swift-app/Sources/Popskill/Views/MatrixView.swift b/swift-app/Sources/Popskill/Views/MatrixView.swift index 268a826..81c8567 100644 --- a/swift-app/Sources/Popskill/Views/MatrixView.swift +++ b/swift-app/Sources/Popskill/Views/MatrixView.swift @@ -1,9 +1,19 @@ import SwiftUI +enum MatrixTableLayout { + static let appColumnWidth: CGFloat = 92 + static let sourceColumnWidth: CGFloat = 184 + static let versionColumnWidth: CGFloat = 86 + static let tokensColumnWidth: CGFloat = 78 + static let callsColumnWidth: CGFloat = 62 + static let actionColumnWidth: CGFloat = 52 +} + /// Skills × Tools — the matrix is Popskill's灵魂主视图: rows = capabilities /// (skill / cli / mcp / agent), columns = AI tools (Claude Code / Codex), /// the cell is a direct toggle. Selecting a row slides an Inspector pane in /// from the right showing the position-and-link section. +@MainActor struct MatrixView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization @@ -11,7 +21,12 @@ struct MatrixView: View { var body: some View { let capabilities = store.capabilities - let sections = filteredSections(in: capabilities) + let usageIndex = MatrixUsageIndex( + summary: store.usageSummary, + skills: store.skills, + packages: store.compositePackages + ) + let sections = filteredSections(in: capabilities, usageIndex: usageIndex) VStack(spacing: 0) { header(capabilities: capabilities) Divider() @@ -27,7 +42,7 @@ struct MatrixView: View { } else if sections.isEmpty { noResultsState } else { - matrixTable(sections: sections) + matrixTable(sections: sections, usageIndex: usageIndex) } } .popPageBackground() @@ -46,7 +61,7 @@ struct MatrixView: View { // MARK: Header private func header(capabilities: [MatrixCapability]) -> some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline, spacing: 16) { VStack(alignment: .leading, spacing: 4) { LocalizedText("sidebar.matrix") @@ -59,17 +74,22 @@ struct MatrixView: View { Spacer(minLength: 16) searchField } - filterChips + metricStrip(capabilities: capabilities) + HStack(spacing: 10) { + filterChips + Spacer(minLength: 8) + sortMenu + } } .padding(.horizontal, 28) - .padding(.top, 24) - .padding(.bottom, 14) + .padding(.top, 22) + .padding(.bottom, 12) } private func subtitle(capabilities: [MatrixCapability]) -> String { let count = capabilities.count let active = store.enabledSkillCount - return localization.string("matrix.subtitle", count, active) + return localization.string("matrix.subtitle", store.bundleCount, count, active) } private var searchField: some View { @@ -105,6 +125,65 @@ struct MatrixView: View { .accessibilityLabel(Text(localization.string("matrix.search.placeholder"))) } + private func metricStrip(capabilities: [MatrixCapability]) -> some View { + let metrics = summaryMetrics(capabilities: capabilities) + return HStack(spacing: 0) { + ForEach(Array(metrics.enumerated()), id: \.element.id) { index, metric in + MatrixSummaryMetricView(metric: metric) + .frame(minWidth: metric.preferredWidth, alignment: .leading) + if index < metrics.count - 1 { + Rectangle() + .fill(Color.popSeparator.opacity(0.65)) + .frame(width: 0.5, height: 32) + .padding(.horizontal, 16) + } + } + Spacer(minLength: 0) + } + } + + private func summaryMetrics(capabilities: [MatrixCapability]) -> [MatrixSummaryMetric] { + [ + MatrixSummaryMetric( + id: "capabilities", + value: "\(capabilities.count)", + title: localization.string("matrix.metric.capabilities"), + tint: .popLabel + ), + MatrixSummaryMetric( + id: "claude", + value: "\(capabilities.filter { $0.apps.claude }.count)", + title: localization.string("matrix.metric.claudeActive"), + tint: .popLabel + ), + MatrixSummaryMetric( + id: "codex", + value: "\(capabilities.filter { $0.apps.codex }.count)", + title: localization.string("matrix.metric.codexActive"), + tint: .popLabel + ), + MatrixSummaryMetric( + id: "stubs", + value: "\(store.stubs.count)", + title: localization.string("matrix.metric.stubs"), + tint: store.stubs.isEmpty ? .popSecondaryLabel : .popStatusWarning + ), + MatrixSummaryMetric( + id: "broken-links", + value: "\(store.brokenLinkCount)", + title: localization.string("matrix.metric.brokenLinks"), + tint: store.brokenLinkCount > 0 ? .popStatusError : .popSecondaryLabel + ), + MatrixSummaryMetric( + id: "tokens", + value: store.usageSummary.map { UsageDisplayFormatter.compactTokens($0.thirtyDayTotalTokens) } ?? "—", + title: localization.string("matrix.metric.tokenUsage"), + tint: .popLabel, + preferredWidth: 116 + ) + ] + } + private var filterChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { @@ -117,6 +196,7 @@ struct MatrixView: View { } } } + .frame(maxWidth: .infinity, alignment: .leading) } private func chipButton(filter: MatrixFilter) -> some View { @@ -131,15 +211,18 @@ struct MatrixView: View { .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) } } - .font(.system(size: 12, weight: active ? .semibold : .regular)) - .foregroundStyle(active ? Color.accentColor : Color.popSecondaryLabel) - .padding(.horizontal, 10) + .font(.system(size: 11.5, weight: active ? .semibold : .regular)) + .foregroundStyle(active ? Color.popCardBackground : Color.popLabel) + .padding(.horizontal, 9) .padding(.vertical, 4) .background( - active ? Color.popAccentSoft : Color.popControlFill, - in: Capsule() + active ? Color.popLabel : Color.popControlFill, + in: RoundedRectangle(cornerRadius: 5, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .strokeBorder(active ? Color.popLabel.opacity(0.10) : Color.popControlStroke, lineWidth: 0.7) ) - .overlay(Capsule().strokeBorder(active ? Color.accentColor.opacity(0.30) : Color.popControlStroke, lineWidth: 0.7)) } .buttonStyle(.plain) .accessibilityAddTraits(active ? .isSelected : []) @@ -151,23 +234,62 @@ struct MatrixView: View { store.matrixTypeFilter = filter } label: { Text(localization.string(filter.titleKey)) - .font(.system(size: 12, weight: active ? .semibold : .regular)) - .foregroundStyle(active ? Color.accentColor : Color.popSecondaryLabel) - .padding(.horizontal, 10) + .font(.system(size: 11.5, weight: active ? .semibold : .regular)) + .foregroundStyle(active ? Color.popCardBackground : Color.popLabel) + .padding(.horizontal, 9) .padding(.vertical, 4) .background( - active ? Color.popAccentSoft : Color.popControlFill, - in: Capsule() + active ? Color.popLabel : Color.popControlFill, + in: RoundedRectangle(cornerRadius: 5, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .strokeBorder(active ? Color.popLabel.opacity(0.10) : Color.popControlStroke, lineWidth: 0.7) ) - .overlay(Capsule().strokeBorder(active ? Color.accentColor.opacity(0.30) : Color.popControlStroke, lineWidth: 0.7)) } .buttonStyle(.plain) .accessibilityAddTraits(active ? .isSelected : []) } + private var sortMenu: some View { + Menu { + ForEach(MatrixSortMode.allCases) { mode in + Button { + store.matrixSortMode = mode + } label: { + Label( + localization.string(mode.titleKey), + systemImage: store.matrixSortMode == mode ? "checkmark" : mode.symbolName + ) + } + } + } label: { + HStack(spacing: 5) { + Image(systemName: store.matrixSortMode.symbolName) + .font(.system(size: 10.5, weight: .semibold)) + Text(localization.string(store.matrixSortMode.titleKey)) + .font(.system(size: 11.5, weight: .medium)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(Color.popTertiaryLabel) + } + .foregroundStyle(Color.popSecondaryLabel) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.popControlFill, in: RoundedRectangle(cornerRadius: 5, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .strokeBorder(Color.popControlStroke, lineWidth: 0.7) + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help(localization.string("matrix.sort.help")) + } + // MARK: Matrix table - private func matrixTable(sections: [CapabilitySection]) -> some View { + private func matrixTable(sections: [CapabilitySection], usageIndex: MatrixUsageIndex) -> some View { ScrollView { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { Section { @@ -182,7 +304,11 @@ struct MatrixView: View { MatrixGroupHeader(group: group, store: store) if !store.collapsedGroups.contains(group.id) { ForEach(group.capabilities, id: \.id) { capability in - MatrixRow(capability: capability, store: store) + if capability.kind == .bundle { + MatrixPackageRow(capability: capability, store: store, usageIndex: usageIndex) + } else { + MatrixRow(capability: capability, store: store, usageIndex: usageIndex) + } Divider().opacity(0.4) } } @@ -192,14 +318,12 @@ struct MatrixView: View { Color.clear.frame(height: 24) } } - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .background(Color.popSurfaceElevated.opacity(0.18), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .background(Color.popSurfaceElevated.opacity(0.42), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.popBorder, lineWidth: 0.7) + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.popBorder.opacity(0.82), lineWidth: 0.7) ) - .shadow(color: .black.opacity(0.035), radius: 12, x: 0, y: 3) .padding(.horizontal, 16) .padding(.bottom, 16) } @@ -233,17 +357,24 @@ struct MatrixView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 14) Text("Claude Code") - .frame(width: 100, alignment: .center) + .frame(width: MatrixTableLayout.appColumnWidth, alignment: .center) Text("Codex") - .frame(width: 100, alignment: .center) + .frame(width: MatrixTableLayout.appColumnWidth, alignment: .center) Text(localization.string("matrix.col.source")) - .frame(width: 220, alignment: .leading) - Spacer().frame(width: 56) + .frame(width: MatrixTableLayout.sourceColumnWidth, alignment: .leading) + Text(localization.string("matrix.col.version")) + .frame(width: MatrixTableLayout.versionColumnWidth, alignment: .leading) + Text(localization.string("matrix.col.tokens")) + .frame(width: MatrixTableLayout.tokensColumnWidth, alignment: .trailing) + Text(localization.string("matrix.col.calls")) + .frame(width: MatrixTableLayout.callsColumnWidth, alignment: .trailing) + Spacer().frame(width: MatrixTableLayout.actionColumnWidth) } - .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(Color.popSecondaryLabel) - .padding(.vertical, 8) - .background(Color.popTableHeaderFill) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(Color.popTertiaryLabel) + .textCase(.uppercase) + .padding(.vertical, 7) + .background(Color.popTableHeaderFill.opacity(0.70)) .overlay(alignment: .bottom) { Rectangle() .fill(Color.popSeparator) @@ -272,17 +403,17 @@ struct MatrixView: View { // MARK: Filtering & grouping - private func filteredSections(in capabilities: [MatrixCapability]) -> [CapabilitySection] { - let q = store.trimmedSearch.lowercased() + private func filteredSections( + in capabilities: [MatrixCapability], + usageIndex: MatrixUsageIndex + ) -> [CapabilitySection] { + let q = SearchTextNormalizer.key(store.trimmedSearch) let visible = capabilities.filter { capability in - store.matrixFilter.includes(capability: capability, store: store) + store.matrixFilter.includes(capability: capability, store: store) && store.matrixTypeFilter.includes(capability: capability) - && (q.isEmpty - || capability.name.lowercased().contains(q) - || (capability.summary ?? "").lowercased().contains(q) - || capability.directory.lowercased().contains(q)) + && capability.matchesSearch(query: q) } - return SkillGrouping.sections(visible) + return SkillGrouping.sections(visible, sort: store.matrixSortMode, usageIndex: usageIndex) } private var noResultsState: some View { @@ -334,11 +465,41 @@ struct MatrixView: View { } } +private struct MatrixSummaryMetric: Identifiable { + let id: String + let value: String + let title: String + let tint: Color + var preferredWidth: CGFloat = 92 +} + +private struct MatrixSummaryMetricView: View { + let metric: MatrixSummaryMetric + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(metric.value) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(metric.tint) + .lineLimit(1) + .minimumScaleFactor(0.82) + Text(metric.title) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(Color.popSecondaryLabel) + .textCase(.uppercase) + .lineLimit(1) + } + .accessibilityElement(children: .combine) + } +} + // MARK: - Filters enum MatrixFilter: String, CaseIterable, Identifiable { case all case updates + case brokenLinks = "broken-links" case claudeOnly = "claude-only" case codexOnly = "codex-only" case inactive @@ -349,6 +510,7 @@ enum MatrixFilter: String, CaseIterable, Identifiable { switch self { case .all: return "matrix.filter.all" case .updates: return "matrix.filter.updates" + case .brokenLinks: return "matrix.filter.brokenLinks" case .claudeOnly: return "matrix.filter.claudeOnly" case .codexOnly: return "matrix.filter.codexOnly" case .inactive: return "matrix.filter.inactive" @@ -358,8 +520,12 @@ enum MatrixFilter: String, CaseIterable, Identifiable { @MainActor func badge(store: PopskillStore) -> Int? { switch self { - case .updates: return store.pendingUpdateCount > 0 ? store.pendingUpdateCount : nil - default: return nil + case .updates: + return store.pendingUpdateCount > 0 ? store.pendingUpdateCount : nil + case .brokenLinks: + return store.brokenLinkCount > 0 ? store.brokenLinkCount : nil + default: + return nil } } @@ -370,6 +536,8 @@ enum MatrixFilter: String, CaseIterable, Identifiable { return true case .updates: return store.hasPendingUpdate(for: capability) + case .brokenLinks: + return capability.hasBrokenLink || capability.package?.hasBrokenLinks(in: store.skills) == true case .claudeOnly: return capability.apps.claude && !capability.apps.codex case .codexOnly: @@ -382,6 +550,7 @@ enum MatrixFilter: String, CaseIterable, Identifiable { enum MatrixTypeFilter: String, CaseIterable, Identifiable { case allTypes + case bundle case skill case agent case cli @@ -392,6 +561,7 @@ enum MatrixTypeFilter: String, CaseIterable, Identifiable { var titleKey: String { switch self { case .allTypes: return "matrix.type.all" + case .bundle: return "matrix.type.bundle" case .skill: return "matrix.type.skill" case .agent: return "matrix.type.agent" case .cli: return "matrix.type.cli" @@ -402,6 +572,7 @@ enum MatrixTypeFilter: String, CaseIterable, Identifiable { func includes(capability: MatrixCapability) -> Bool { switch self { case .allTypes: return true + case .bundle: return capability.kind == .bundle case .skill: return capability.kind == .skill case .agent: return capability.kind == .agent case .cli: return capability.kind == .cli diff --git a/swift-app/Sources/Popskill/Views/OnboardingWizardView.swift b/swift-app/Sources/Popskill/Views/OnboardingWizardView.swift index 966ee0d..027fa72 100644 --- a/swift-app/Sources/Popskill/Views/OnboardingWizardView.swift +++ b/swift-app/Sources/Popskill/Views/OnboardingWizardView.swift @@ -11,6 +11,7 @@ import SwiftUI /// 3. Storage + sync — confirm SSOT + pick provider (iCloud recommended on /// Mac with iCloud Drive available, Git otherwise). /// 4. Done — open matrix. +@MainActor struct OnboardingWizardView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization diff --git a/swift-app/Sources/Popskill/Views/RootView.swift b/swift-app/Sources/Popskill/Views/RootView.swift index e3227f1..329118e 100644 --- a/swift-app/Sources/Popskill/Views/RootView.swift +++ b/swift-app/Sources/Popskill/Views/RootView.swift @@ -4,9 +4,15 @@ import SwiftUI /// with 3 sectioned groups (操控台 / 来源 / 维护) + Settings + Spotlight trigger. /// Each detail-area view is its own file and pulls data straight off the /// shared `PopskillStore`. +@MainActor struct RootView: View { @State private var store = PopskillStore() @Environment(\.popskillLocalization) private var localization + private static let sidebarRelativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() var body: some View { ZStack(alignment: .top) { @@ -68,6 +74,8 @@ struct RootView: View { )) { Section { row(.matrix) + matrixShortcutRows + sidebarSyncStatus } header: { sectionHeader(.control) } Section { @@ -128,6 +136,194 @@ struct RootView: View { } } + @ViewBuilder + private var matrixShortcutRows: some View { + let statusFilters: [MatrixFilter] = [.claudeOnly, .codexOnly, .brokenLinks] + let typeFilters: [MatrixTypeFilter] = [.bundle, .skill, .agent, .mcp, .cli] + let counts = store.matrixShortcutCounts() + if counts.capabilityCount > 0 { + VStack(alignment: .leading, spacing: 7) { + shortcutHeader("sidebar.matrixFilters") + ForEach(statusFilters) { filter in + let count = counts.count(for: filter) + if count > 0 { + sidebarShortcutButton( + titleKey: filter.titleKey, + count: count, + symbolName: sidebarSymbol(for: filter), + warning: filter == .brokenLinks, + active: store.currentSelection == .matrix + && store.matrixFilter == filter + && store.matrixTypeFilter == .allTypes + ) { + store.showMatrix(filter: filter) + } + } + } + + shortcutHeader("sidebar.matrixTypes") + .padding(.top, 4) + ForEach(typeFilters) { filter in + let count = counts.count(for: filter) + if count > 0 { + sidebarShortcutButton( + titleKey: filter.titleKey, + count: count, + symbolName: sidebarSymbol(for: filter), + active: store.currentSelection == .matrix + && store.matrixFilter == .all + && store.matrixTypeFilter == filter + ) { + store.showMatrix(typeFilter: filter) + } + } + } + } + .padding(.top, 2) + .padding(.bottom, 4) + .listRowInsets(EdgeInsets(top: 2, leading: 18, bottom: 6, trailing: 12)) + } + } + + private var sidebarSyncStatus: some View { + let provider = SyncProvider(rawValue: store.lastSyncProvider) ?? .git + let statusTint = provider.actionable ? Color.accentColor : Color.popStatusWarning + return Button { + store.showSettings() + } label: { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 6) { + Text(localization.string("sidebar.sync")) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(Color.popTertiaryLabel) + .textCase(.uppercase) + .tracking(0.4) + Spacer(minLength: 8) + Label(localization.string("sidebar.sync.settings"), systemImage: "gearshape") + .font(.system(size: 10.5, weight: .medium)) + .labelStyle(.titleAndIcon) + .foregroundStyle(Color.popSecondaryLabel) + } + HStack(spacing: 8) { + Image(systemName: provider.symbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(statusTint) + .frame(width: 16) + VStack(alignment: .leading, spacing: 1) { + Text(localization.string(provider.titleKey)) + .font(.system(size: 12.2, weight: .medium)) + .foregroundStyle(Color.popSidebarTitle) + .lineLimit(1) + Text(syncStatusSubtitle(provider: provider)) + .font(.system(size: 10.5)) + .foregroundStyle(Color.popSecondaryLabel) + .lineLimit(1) + } + Spacer(minLength: 8) + Circle() + .fill(statusTint) + .frame(width: 7, height: 7) + .accessibilityHidden(true) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(Color.popControlFill.opacity(0.62), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.popControlStroke.opacity(0.75), lineWidth: 0.7) + ) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .help(localization.string("sidebar.sync.openSettings")) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 6, trailing: 12)) + } + + private func syncStatusSubtitle(provider: SyncProvider) -> String { + if !provider.actionable { + return localization.string("settings.sync.soon") + } + guard let lastSyncAt = store.lastSyncAt else { + return localization.string("sidebar.sync.never") + } + let relative = Self.sidebarRelativeFormatter.localizedString(for: lastSyncAt, relativeTo: Date()) + return localization.string("sidebar.sync.last", relative) + } + + private func shortcutHeader(_ key: String) -> some View { + Text(localization.string(key)) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(Color.popTertiaryLabel) + .textCase(.uppercase) + .tracking(0.4) + .padding(.leading, 4) + } + + private func sidebarShortcutButton( + titleKey: String, + count: Int, + symbolName: String, + warning: Bool = false, + active: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 7) { + Image(systemName: symbolName) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(active ? Color.accentColor : Color.popSecondaryLabel) + .frame(width: 16) + Text(localization.string(titleKey)) + .font(.system(size: 12)) + .foregroundStyle(Color.popSidebarTitle) + .lineLimit(1) + Spacer(minLength: 8) + Text("\(count)") + .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) + .foregroundStyle(warning && count > 0 ? Color.popStatusWarning : Color.popSecondaryLabel) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background( + Capsule().fill( + (warning && count > 0 ? Color.popStatusWarning : Color.popSecondaryLabel).opacity(0.13) + ) + ) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + active ? Color.accentColor.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityAddTraits(active ? .isSelected : []) + } + + private func sidebarSymbol(for filter: MatrixFilter) -> String { + switch filter { + case .all: return "square.grid.3x3" + case .updates: return "arrow.triangle.2.circlepath" + case .brokenLinks: return "exclamationmark.triangle" + case .claudeOnly: return TargetApp.claude.symbolName + case .codexOnly: return TargetApp.codex.symbolName + case .inactive: return "moon" + } + } + + private func sidebarSymbol(for filter: MatrixTypeFilter) -> String { + switch filter { + case .allTypes: return "square.stack.3d.up" + case .bundle: return CapabilityKind.bundle.symbol + case .skill: return CapabilityKind.skill.symbol + case .agent: return CapabilityKind.agent.symbol + case .cli: return CapabilityKind.cli.symbol + case .mcp: return CapabilityKind.mcp.symbol + } + } + // MARK: Error toast private func errorToast(message: String) -> some View { diff --git a/swift-app/Sources/Popskill/Views/SettingsView.swift b/swift-app/Sources/Popskill/Views/SettingsView.swift index f7c97af..463c8ad 100644 --- a/swift-app/Sources/Popskill/Views/SettingsView.swift +++ b/swift-app/Sources/Popskill/Views/SettingsView.swift @@ -6,13 +6,14 @@ import SwiftUI /// The non-Git providers are placeholder buttons (sidecar gates them). /// 3. 数据源管理 — count + jump to Sources view. /// 4. 重新引导 — re-run the 5-step Onboarding (S6) explicitly. +@MainActor struct SettingsView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization @State private var syncProvider: SyncProvider = .git @State private var pendingSync: Bool = false - @State private var syncMessage: String? + @State private var syncSummary: SyncResultSummary? var body: some View { ScrollView { @@ -136,15 +137,22 @@ struct SettingsView: View { .controlSize(.small) .disabled(pendingSync || !syncProvider.actionable) + Button { + Task { await runSync(.status) } + } label: { + Label(localization.string("settings.sync.status"), systemImage: "list.bullet.rectangle") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(pendingSync || !syncProvider.actionable) + if pendingSync { ProgressView().controlSize(.small) } Spacer() } - if let syncMessage { - Text(syncMessage) - .font(.caption) - .foregroundStyle(Color.popSecondaryLabel) + if let syncSummary { + syncStatusPanel(syncSummary) } } .padding(16) @@ -156,6 +164,7 @@ struct SettingsView: View { Button { syncProvider = provider store.lastSyncProvider = provider.rawValue + syncSummary = nil } label: { HStack(spacing: 10) { Image(systemName: syncProvider == provider ? "largecircle.fill.circle" : "circle") @@ -185,20 +194,115 @@ struct SettingsView: View { .buttonStyle(.plain) } + private func syncStatusPanel(_ summary: SyncResultSummary) -> some View { + let tint = syncStatusColor(summary.state) + let detailLines = summary.details.map(syncDetailText) + + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: syncStatusSymbol(summary.state)) + .foregroundStyle(tint) + Text(localization.string(syncStatusTitleKey(summary.state))) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.popLabel) + Spacer() + } + Text(summary.message) + .font(.caption) + .foregroundStyle(Color.popSecondaryLabel) + .textSelection(.enabled) + .lineLimit(4) + if !detailLines.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(detailLines.indices, id: \.self) { index in + Text(detailLines[index]) + .font(.caption2) + .foregroundStyle(Color.popTertiaryLabel) + .textSelection(.enabled) + .lineLimit(2) + .truncationMode(.middle) + } + } + } + } + .padding(10) + .background(tint.opacity(0.09), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(tint.opacity(0.24), lineWidth: 0.7) + } + } + + private func syncStatusTitleKey(_ state: SyncResultSummary.State) -> String { + switch state { + case .success: return "settings.sync.result.success" + case .failure: return "settings.sync.result.failure" + case .unavailable: return "settings.sync.result.unavailable" + case .unknown: return "settings.sync.result.unknown" + } + } + + private func syncStatusSymbol(_ state: SyncResultSummary.State) -> String { + switch state { + case .success: return "checkmark.circle.fill" + case .failure: return "exclamationmark.circle.fill" + case .unavailable: return "clock.fill" + case .unknown: return "info.circle.fill" + } + } + + private func syncStatusColor(_ state: SyncResultSummary.State) -> Color { + switch state { + case .success: return Color.popStatusOK + case .failure: return Color.popStatusError + case .unavailable: return Color.popStatusWarning + case .unknown: return Color.popStatusNeutral + } + } + + private func syncDetailText(_ detail: SyncResultSummary.Detail) -> String { + switch detail { + case .exitCode(let code): + return localization.string("settings.sync.detail.exitCode", code) + case .localEndpoint(let path, let count): + return localization.string("settings.sync.detail.local", endpointDetail(path: path, count: count)) + case .remoteEndpoint(let path, let count): + return localization.string("settings.sync.detail.remote", endpointDetail(path: path, count: count)) + } + } + + private func endpointDetail(path: String?, count: Int?) -> String { + var parts: [String] = [] + if let count { + parts.append(localization.string("settings.sync.detail.count", count)) + } + if let path { + parts.append(path) + } + return parts.joined(separator: " - ") + } + @MainActor private func runSync(_ action: SyncAction) async { guard !pendingSync, syncProvider.actionable else { return } pendingSync = true - syncMessage = nil + syncSummary = nil defer { pendingSync = false } do { let result = try await store.client.sync(action: action.rawValue, provider: syncProvider.rawValue) - syncMessage = result.message ?? localization.string("settings.sync.done", action.rawValue, syncProvider.rawValue) - if result.ok == true { + syncSummary = result.summary( + successMessage: localization.string("settings.sync.done", action.rawValue, syncProvider.rawValue), + emptyMessage: localization.string("settings.sync.noDetails") + ) + if result.ok == true && action != .status { store.lastSyncAt = Date() } } catch { - syncMessage = error.localizedDescription + syncSummary = SyncResultSummary( + state: .failure, + message: error.localizedDescription, + details: [] + ) } } diff --git a/swift-app/Sources/Popskill/Views/SourcesView.swift b/swift-app/Sources/Popskill/Views/SourcesView.swift index e38885b..084a920 100644 --- a/swift-app/Sources/Popskill/Views/SourcesView.swift +++ b/swift-app/Sources/Popskill/Views/SourcesView.swift @@ -4,6 +4,7 @@ import SwiftUI /// enable/disable + remove + a small `addOpen` popover for adding a new /// `owner/name@branch`. Full multi-type wizard (npm / brew / folder / zip) is /// scheduled for v0.4 and surfaces as the disabled buttons here. +@MainActor struct SourcesView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization @@ -288,6 +289,7 @@ struct AddSourceInput: Equatable { } } +@MainActor private struct AddSourcePopover: View { @Bindable var store: PopskillStore @Binding var isPresented: Bool diff --git a/swift-app/Sources/Popskill/Views/SpotlightView.swift b/swift-app/Sources/Popskill/Views/SpotlightView.swift index 925a524..cebf057 100644 --- a/swift-app/Sources/Popskill/Views/SpotlightView.swift +++ b/swift-app/Sources/Popskill/Views/SpotlightView.swift @@ -2,13 +2,14 @@ import SwiftUI /// ⌘K command palette. Opens over the entire app, search box auto-focused. /// Two sections shown together, deduped by source: -/// 1. **能力** — top N (8) `Skill` hits ranked by `SkillSearchScorer`. +/// 1. **能力** — top package / skill hits ranked by local scorers. /// 2. **操作** — fixed quick actions (refresh / link-health / updates / /// settings) filtered by query substring match. /// /// Keyboard: ↑↓ moves highlight, Enter triggers the highlighted row, Esc /// closes. Clicking the scrim closes too. Pressing ⌘1 / ⌘2 on a skill row /// toggles Claude / Codex without leaving the palette. +@MainActor struct SpotlightView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization @@ -17,7 +18,13 @@ struct SpotlightView: View { @State private var highlighted: Int = 0 @FocusState private var queryFocused: Bool - private let maxSkillHits = 8 + private let maxCapabilityHits = 8 + private let maxPackageHits = 3 + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() var body: some View { ZStack { @@ -121,17 +128,17 @@ struct SpotlightView: View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - if !skillHits.isEmpty { + if !capabilityItems.isEmpty { Section { - ForEach(Array(skillHits.enumerated()), id: \.offset) { offset, hit in + ForEach(Array(capabilityItems.enumerated()), id: \.offset) { offset, item in row( - item: .skill(hit.skill, hit.hit), + item: item, index: offset, proxy: proxy ) } } header: { - sectionHeader(localization.string("spotlight.section.capabilities")) + sectionHeader(localization.string(capabilitySectionTitleKey)) } } if !actionHits.isEmpty { @@ -139,7 +146,7 @@ struct SpotlightView: View { ForEach(Array(actionHits.enumerated()), id: \.offset) { offset, action in row( item: .action(action), - index: skillHits.count + offset, + index: capabilityItems.count + offset, proxy: proxy ) } @@ -210,9 +217,10 @@ struct SpotlightView: View { private func icon(for item: SpotlightItem) -> some View { Group { switch item { + case let .package(package, _): + PackageAvatar(name: package.name, identifier: package.id, size: 24) case let .skill(skill, _): - InitialAvatarView(name: skill.name, identifier: skill.id) - .frame(width: 24, height: 24) + InitialAvatarView(name: skill.name, identifier: skill.id, size: 24) case let .action(action): Image(systemName: action.symbol) .font(.system(size: 13, weight: .semibold)) @@ -225,6 +233,7 @@ struct SpotlightView: View { private func primaryLabel(for item: SpotlightItem) -> String { switch item { + case let .package(package, _): return package.name case let .skill(skill, _): return skill.name case let .action(action): return localization.string(action.titleKey) } @@ -232,10 +241,28 @@ struct SpotlightView: View { private func secondaryLabel(for item: SpotlightItem) -> String? { switch item { + case let .package(package, hit): + if !hit.matchedComponents.isEmpty { + return localization.string( + "spotlight.bundle.matchedComponents", + hit.matchedComponents.joined(separator: " · ") + ) + } + if isEmptyQuery, + let snapshot = package.usageSnapshot(using: store.usageSummary, skills: store.skills), + let label = recentUsageLabel(calls: snapshot.usageEvents, lastUsedAt: snapshot.lastUsedAt) { + return label + } + return PackageComponentCompositionFormatter.summary(for: package, localization: localization) case let .skill(skill, hit): if !hit.matchedTriggers.isEmpty { return hit.matchedTriggers.prefix(2).joined(separator: " · ") } + if isEmptyQuery, + let snapshot = skill.usageSnapshot(using: store.usageSummary), + let label = recentUsageLabel(calls: snapshot.usageEvents, lastUsedAt: snapshot.lastUsedAt) { + return label + } if let summary = skill.capabilitySummary, !summary.isEmpty { return summary } return skill.description case let .action(action): @@ -243,9 +270,26 @@ struct SpotlightView: View { } } + private func recentUsageLabel(calls: Int, lastUsedAt: Date?) -> String? { + guard calls > 0 || lastUsedAt != nil else { + return nil + } + let relative = lastUsedAt.map { + Self.relativeFormatter.localizedString(for: $0, relativeTo: Date()) + } ?? localization.string("spotlight.recentUsage.unknown") + return localization.string("spotlight.recentUsage", relative, UsageDisplayFormatter.compactCount(calls)) + } + @ViewBuilder private func trailing(for item: SpotlightItem) -> some View { switch item { + case .package: + Text(localization.string("matrix.type.bundle").uppercased()) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Color.popSectionPurple) + .padding(.horizontal, 5) + .padding(.vertical, 2.5) + .background(Color.popSectionPurple.opacity(0.12), in: Capsule()) case let .skill(skill, _): HStack(spacing: 6) { quickToggle(skill: skill, app: .claude, shortcut: "1") @@ -316,13 +360,50 @@ struct SpotlightView: View { // MARK: Derived + private var isEmptyQuery: Bool { + localQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var capabilitySectionTitleKey: String { + isEmptyQuery ? "spotlight.section.recent" : "spotlight.section.capabilities" + } + + private var packageHits: [(package: CapabilityPackage, hit: PackageSearchHit)] { + let q = localQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !q.isEmpty else { + return SpotlightRecentRanker.recentPackages( + store.compositePackages, + skills: store.skills, + summary: store.usageSummary + ) + .prefix(maxPackageHits) + .map { ($0, PackageSearchHit.recent) } + } + + return store.compositePackages + .compactMap { package -> (CapabilityPackage, PackageSearchHit)? in + guard let hit = PackageSearchScorer.score(package: package, query: q) else { return nil } + return (package, hit) + } + .sorted { lhs, rhs in + if lhs.1.score != rhs.1.score { return lhs.1.score > rhs.1.score } + return lhs.0.name.localizedCaseInsensitiveCompare(rhs.0.name) == .orderedAscending + } + .prefix(maxPackageHits) + .map { ($0.0, $0.1) } + } + private var skillHits: [(skill: Skill, hit: SkillSearchHit)] { let q = localQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let remainingSlots = max(0, maxCapabilityHits - packageHits.count) guard !q.isEmpty else { - // Empty query: show recently installed / updated skills (max N). - return store.skills - .sorted { ($0.lastLifecycleTimestamp ?? 0) > ($1.lastLifecycleTimestamp ?? 0) } - .prefix(maxSkillHits) + // Empty query: mirror the HTML prototype's "recently used" command + // palette, falling back to lifecycle recency before usage has been scanned. + return SpotlightRecentRanker.recentSkills( + store.skills, + summary: store.usageSummary + ) + .prefix(remainingSlots) .map { ($0, SkillSearchHit(score: 0, matchedTriggers: [], matchedOnName: false)) } } return store.skills @@ -331,22 +412,26 @@ struct SpotlightView: View { return (skill, hit) } .sorted { $0.1.score > $1.1.score } - .prefix(maxSkillHits) + .prefix(remainingSlots) .map { ($0.0, $0.1) } } private var actionHits: [SpotlightAction] { - let q = localQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let q = SearchTextNormalizer.key(localQuery) return SpotlightAction.all.filter { action in guard !q.isEmpty else { return true } - let title = localization.string(action.titleKey).lowercased() - let subtitle = localization.string(action.subtitleKey).lowercased() - return title.contains(q) || subtitle.contains(q) + return SearchTextNormalizer.matches(localization.string(action.titleKey), query: q) + || SearchTextNormalizer.matches(localization.string(action.subtitleKey), query: q) } } private var combined: [SpotlightItem] { - skillHits.map { .skill($0.skill, $0.hit) } + actionHits.map { .action($0) } + capabilityItems + actionHits.map { .action($0) } + } + + private var capabilityItems: [SpotlightItem] { + packageHits.map { .package($0.package, $0.hit) } + + skillHits.map { .skill($0.skill, $0.hit) } } // MARK: Activation @@ -360,6 +445,10 @@ struct SpotlightView: View { private func activate(index: Int) { guard combined.indices.contains(index) else { return } switch combined[index] { + case let .package(package, _): + store.currentSelection = .matrix + store.selectCapability(MatrixCapability.packageCapabilityID(for: package.id)) + close() case let .skill(skill, _): store.currentSelection = .matrix store.selectSkill(skill.id) @@ -393,9 +482,105 @@ struct SpotlightView: View { } } +enum SpotlightRecentRanker { + static func recentPackages( + _ packages: [CapabilityPackage], + skills: [Skill], + summary: UsageSummary? + ) -> [CapabilityPackage] { + packages + .map { package in + ( + package: package, + snapshot: package.usageSnapshot(using: summary, skills: skills) + ) + } + .sorted { lhs, rhs in + isRankedBefore( + lhsLastUsed: lhs.snapshot?.lastUsedAt, + lhsCalls: lhs.snapshot?.usageEvents ?? 0, + lhsTokens: lhs.snapshot?.totalTokens ?? 0, + lhsLifecycle: lhs.package.lastLifecycleTimestamp, + lhsName: lhs.package.name, + rhsLastUsed: rhs.snapshot?.lastUsedAt, + rhsCalls: rhs.snapshot?.usageEvents ?? 0, + rhsTokens: rhs.snapshot?.totalTokens ?? 0, + rhsLifecycle: rhs.package.lastLifecycleTimestamp, + rhsName: rhs.package.name + ) + } + .map(\.package) + } + + static func recentSkills(_ skills: [Skill], summary: UsageSummary?) -> [Skill] { + skills + .map { skill in + ( + skill: skill, + snapshot: skill.usageSnapshot(using: summary) + ) + } + .sorted { lhs, rhs in + isRankedBefore( + lhsLastUsed: lhs.snapshot?.lastUsedAt, + lhsCalls: lhs.snapshot?.usageEvents ?? 0, + lhsTokens: lhs.snapshot?.totalTokens ?? 0, + lhsLifecycle: lhs.skill.lastLifecycleTimestamp, + lhsName: lhs.skill.name, + rhsLastUsed: rhs.snapshot?.lastUsedAt, + rhsCalls: rhs.snapshot?.usageEvents ?? 0, + rhsTokens: rhs.snapshot?.totalTokens ?? 0, + rhsLifecycle: rhs.skill.lastLifecycleTimestamp, + rhsName: rhs.skill.name + ) + } + .map(\.skill) + } + + private static func isRankedBefore( + lhsLastUsed: Date?, + lhsCalls: Int, + lhsTokens: Int64, + lhsLifecycle: Int?, + lhsName: String, + rhsLastUsed: Date?, + rhsCalls: Int, + rhsTokens: Int64, + rhsLifecycle: Int?, + rhsName: String + ) -> Bool { + switch (lhsLastUsed, rhsLastUsed) { + case let (lhs?, rhs?) where lhs != rhs: + return lhs > rhs + case (_?, nil): + return true + case (nil, _?): + return false + default: + break + } + + if lhsCalls != rhsCalls { + return lhsCalls > rhsCalls + } + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } + + let lhsLifecycle = lhsLifecycle ?? 0 + let rhsLifecycle = rhsLifecycle ?? 0 + if lhsLifecycle != rhsLifecycle { + return lhsLifecycle > rhsLifecycle + } + + return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending + } +} + // MARK: - Items private enum SpotlightItem { + case package(CapabilityPackage, PackageSearchHit) case skill(Skill, SkillSearchHit) case action(SpotlightAction) } @@ -427,6 +612,77 @@ struct SpotlightAction: Identifiable { Task { await store.bootstrap() } } ), + SpotlightAction( + id: "usage-scan", + titleKey: "spotlight.action.usageScan.title", + subtitleKey: "spotlight.action.usageScan.subtitle", + symbol: "chart.bar.doc.horizontal", + tint: .green, + perform: { store in + store.currentSelection = .insights + Task { await store.refreshUsageScan() } + } + ), + SpotlightAction( + id: "show-bundles", + titleKey: "spotlight.action.showBundles.title", + subtitleKey: "spotlight.action.showBundles.subtitle", + symbol: "shippingbox", + tint: .popSectionPurple, + perform: { store in + store.showMatrix(typeFilter: .bundle) + } + ), + SpotlightAction( + id: "show-skills", + titleKey: "spotlight.action.showSkills.title", + subtitleKey: "spotlight.action.showSkills.subtitle", + symbol: CapabilityKind.skill.symbol, + tint: .accentColor, + perform: { store in + store.showMatrix(typeFilter: .skill) + } + ), + SpotlightAction( + id: "show-cli", + titleKey: "spotlight.action.showCLI.title", + subtitleKey: "spotlight.action.showCLI.subtitle", + symbol: CapabilityKind.cli.symbol, + tint: .blue, + perform: { store in + store.showMatrix(typeFilter: .cli) + } + ), + SpotlightAction( + id: "show-mcp", + titleKey: "spotlight.action.showMCP.title", + subtitleKey: "spotlight.action.showMCP.subtitle", + symbol: CapabilityKind.mcp.symbol, + tint: .mint, + perform: { store in + store.showMatrix(typeFilter: .mcp) + } + ), + SpotlightAction( + id: "show-broken-links", + titleKey: "spotlight.action.showBrokenLinks.title", + subtitleKey: "spotlight.action.showBrokenLinks.subtitle", + symbol: "exclamationmark.triangle", + tint: .red, + perform: { store in + store.showMatrix(filter: .brokenLinks) + } + ), + SpotlightAction( + id: "show-inactive", + titleKey: "spotlight.action.showInactive.title", + subtitleKey: "spotlight.action.showInactive.subtitle", + symbol: "pause.circle", + tint: .orange, + perform: { store in + store.showMatrix(filter: .inactive) + } + ), SpotlightAction( id: "link-health", titleKey: "spotlight.action.linkHealth.title", diff --git a/swift-app/Sources/Popskill/Views/UpdatesView.swift b/swift-app/Sources/Popskill/Views/UpdatesView.swift index 3799538..7081bbd 100644 --- a/swift-app/Sources/Popskill/Views/UpdatesView.swift +++ b/swift-app/Sources/Popskill/Views/UpdatesView.swift @@ -3,6 +3,7 @@ import SwiftUI /// Updates — lists every skill whose remote content hash differs from local. /// On `.task` we re-run `client.checkUpdates()` so the badge in the sidebar /// reflects the latest scan; the user can also trigger a manual re-scan. +@MainActor struct UpdatesView: View { @Bindable var store: PopskillStore @Environment(\.popskillLocalization) private var localization @@ -24,7 +25,7 @@ struct UpdatesView: View { } .buttonStyle(.bordered) .controlSize(.small) - .disabled(loading) + .disabled(loading || store.updatesRefreshInFlight) Button { Task { await updateAll() } } label: { @@ -35,7 +36,7 @@ struct UpdatesView: View { // Also disable while a scan or per-row update is in // flight — clicking "全部更新" mid-scan stacks two // concurrent rescans and confuses pendingUpdate state. - .disabled(store.updates.isEmpty || loading || !pendingUpdate.isEmpty) + .disabled(store.updates.isEmpty || loading || store.updatesRefreshInFlight || !pendingUpdate.isEmpty) } } @@ -59,7 +60,7 @@ struct UpdatesView: View { } private var subtitle: String { - if loading { + if loading || store.updatesRefreshInFlight { return localization.string("updates.subtitleScanning", store.updates.count) } if let lastScanAt = store.lastUpdatesRefreshAt { diff --git a/swift-app/Tests/PopskillTests/CommonViewsTests.swift b/swift-app/Tests/PopskillTests/CommonViewsTests.swift index 44fa3cc..ea47053 100644 --- a/swift-app/Tests/PopskillTests/CommonViewsTests.swift +++ b/swift-app/Tests/PopskillTests/CommonViewsTests.swift @@ -42,6 +42,15 @@ struct CommonViewsTests { #expect(PackageAvatar.computeInitials(for: "---") == "S") } + @Test + func packageComponentTreePrefixMarksLastRow() { + #expect(PackageComponentTreePrefix.value(index: 0, count: 1) == "└─") + #expect(PackageComponentTreePrefix.value(index: 0, count: 3) == "├─") + #expect(PackageComponentTreePrefix.value(index: 1, count: 3) == "├─") + #expect(PackageComponentTreePrefix.value(index: 2, count: 3) == "└─") + #expect(PackageComponentTreePrefix.value(index: 0, count: 0) == "├─") + } + @Test func sectionAccentIndexWrapsForwardAndBackward() { #expect(PopskillSectionAccent.index(for: 0) == 0) diff --git a/swift-app/Tests/PopskillTests/LocalizationTests.swift b/swift-app/Tests/PopskillTests/LocalizationTests.swift index 467a828..0e61e82 100644 --- a/swift-app/Tests/PopskillTests/LocalizationTests.swift +++ b/swift-app/Tests/PopskillTests/LocalizationTests.swift @@ -25,4 +25,60 @@ struct LocalizationTests { #expect(localization.string("library.summary", 2, 61, 24) == "2 个能力包 · 61 个 Skill · 24 个已启用") } + + @Test + func inspectorTabLabelsAreLocalized() { + let localization = PopskillLocalization(language: .simplifiedChinese) + + #expect(localization.string("matrix.inspector.tab.overview") == "概览") + #expect(localization.string("matrix.inspector.tab.usage") == "用量") + #expect(localization.string("matrix.inspector.tab.paths") == "路径") + #expect(localization.string("matrix.inspector.tab.sync") == "同步") + #expect(localization.string("matrix.inspector.header.calls", "412") == "412 次调用") + } + + @Test + func inspectorSkillActionLabelsAreLocalized() { + let localization = PopskillLocalization(language: .simplifiedChinese) + + #expect(localization.string("matrix.skill.action.openReadme") == "编辑 prompt") + #expect(localization.string("matrix.skill.action.checkUpdates") == "检查更新") + #expect(localization.string("matrix.skill.action.openSource") == "打开来源") + #expect(localization.string("matrix.skill.action.revealInFinder") == "在 Finder 中显示") + #expect(localization.string("matrix.package.action.activateClaude") == "全部激活到 Claude") + #expect(localization.string("matrix.package.activation.remaining", 2, 1) == "Claude 还差 2 个 · Codex 还差 1 个") + #expect(localization.string("package.componentComposition.skill.other", 6) == "6 项 Skill") + #expect(localization.string("matrix.inspector.section.machine") == "这台机器") + #expect(localization.string("matrix.machine.firstActivated") == "首次激活") + #expect(localization.string("matrix.machine.tokens") == "近30天 tokens") + #expect(localization.string("matrix.machine.topComponent") == "最常用组件") + #expect(localization.string("matrix.inspector.section.source") == "来源与路径") + #expect(localization.string("matrix.source.repository") == "仓库") + #expect(localization.string("matrix.source.localStore") == "本地真身") + #expect(localization.string("matrix.source.license") == "License") + #expect(localization.string("matrix.source.requires") == "依赖命令") + #expect(localization.string("matrix.package.sync.status") == "同步状态") + #expect(localization.string("matrix.package.sync.linkHealthSummary", 2, 1, 3) == "2 正常 · 1 断链 · 3 未启用") + #expect(localization.string("matrix.package.version.components") == "组件版本") + #expect(localization.string("matrix.skill.manifest.version") == "版本") + #expect(localization.string("matrix.skill.manifest.requires.available") == "可用") + #expect(localization.string("matrix.skill.manifest.requires.missing") == "缺失") + #expect(localization.string("matrix.package.coverage.stubbed", 1) == "1 占位") + #expect(localization.string("matrix.package.coverage.off", 3) == "3 未启用") + } + + @Test + func spotlightUsageScanActionIsLocalized() { + let localization = PopskillLocalization(language: .simplifiedChinese) + + #expect(localization.string("spotlight.action.usageScan.title") == "重新扫描用量") + #expect(localization.string("spotlight.action.usageScan.subtitle") == "扫描最近 transcript,刷新调用与 tokens") + #expect(localization.string("spotlight.action.showBundles.title") == "查看套装") + #expect(localization.string("spotlight.action.showSkills.title") == "查看 Skill") + #expect(localization.string("spotlight.action.showCLI.title") == "查看 CLI") + #expect(localization.string("spotlight.action.showMCP.title") == "查看 MCP") + #expect(localization.string("spotlight.action.showBrokenLinks.title") == "查看断链") + #expect(localization.string("spotlight.action.showInactive.title") == "查看未启用") + #expect(localization.string("spotlight.section.recent") == "最近使用") + } } diff --git a/swift-app/Tests/PopskillTests/PackageSearchScorerTests.swift b/swift-app/Tests/PopskillTests/PackageSearchScorerTests.swift new file mode 100644 index 0000000..cdcd778 --- /dev/null +++ b/swift-app/Tests/PopskillTests/PackageSearchScorerTests.swift @@ -0,0 +1,118 @@ +@testable import Popskill +import Testing + +struct PackageSearchScorerTests { + @Test + func queryMatchesPackageNameAndSource() { + let package = demoPackage() + + let hit = PackageSearchScorer.score(package: package, query: "feishu") + + #expect(hit != nil) + #expect(hit?.matchedOnName == true) + #expect((hit?.score ?? 0) > 700) + } + + @Test + func queryMatchesComponentNamesAndKinds() { + let package = demoPackage() + + let cliHit = PackageSearchScorer.score(package: package, query: "lark-cli") + let mcpHit = PackageSearchScorer.score(package: package, query: "mcp") + + #expect(cliHit?.matchedComponents == ["lark-cli"]) + #expect(mcpHit?.matchedComponents.contains("Lark OpenAPI MCP") == true) + } + + @Test + func spaceSeparatedQueryMatchesDashedComponentName() { + let package = demoPackage() + + let hit = PackageSearchScorer.score(package: package, query: "lark cli") + + #expect(hit?.matchedComponents == ["lark-cli"]) + #expect((hit?.score ?? 0) > 0) + } + + @Test + func separatedQueryMatchesCompactRepositoryName() { + let package = demoPackage( + sourceLocation: "github.com/larksuite/cli", + repoOwner: "larksuite", + repoName: "cli" + ) + + let hit = PackageSearchScorer.score(package: package, query: "lark suite") + + #expect(hit != nil) + } + + @Test + func unrelatedQueryReturnsNil() { + let package = demoPackage() + + #expect(PackageSearchScorer.score(package: package, query: "pdf") == nil) + } + + private func demoPackage( + sourceLocation: String = "popskill/builtin/lark", + repoOwner: String = "larksuite", + repoName: String = "cli" + ) -> CapabilityPackage { + CapabilityPackage( + id: "pkg:lark", + type: .composite, + name: "Feishu / Lark", + vendor: "ByteDance", + summary: "Composite office package", + source: PackageSource( + kind: "builtin", + location: sourceLocation, + updateStrategy: "manual", + repoOwner: repoOwner, + repoName: repoName, + repoBranch: "main", + readmeUrl: nil + ), + components: PackageComponents( + cli: [ + PackageComponent( + id: "lark-cli", + name: "lark-cli", + kind: "cli", + required: true, + installed: true, + status: "detected", + location: nil + ) + ], + skills: [ + PackageComponent( + id: "lark-doc", + name: "Lark Doc", + kind: "skill", + required: true, + installed: true, + status: "installed", + location: "lark-doc" + ) + ], + mcp: [ + PackageComponent( + id: "lark-openapi-mcp", + name: "Lark OpenAPI MCP", + kind: "mcp", + required: false, + installed: false, + status: "registry-reference", + location: "anthropic-mcp-registry/bytedance/lark-openapi-mcp" + ) + ], + agents: [] + ), + configSchema: [], + installed: true, + lifecycle: .untracked + ) + } +} diff --git a/swift-app/Tests/PopskillTests/PopskillStoreTests.swift b/swift-app/Tests/PopskillTests/PopskillStoreTests.swift index 697846d..4d2b70a 100644 --- a/swift-app/Tests/PopskillTests/PopskillStoreTests.swift +++ b/swift-app/Tests/PopskillTests/PopskillStoreTests.swift @@ -1,4 +1,5 @@ @testable import Popskill +import Foundation import Testing @MainActor @@ -33,4 +34,272 @@ struct PopskillStoreTests { #expect(store.selectedSkillID == nil) #expect(store.inspectorOpen == false) } + + @Test + func showMatrixShortcutSetsFiltersAndClearsTransientContext() { + let store = PopskillStore() + store.currentSelection = .settings + store.searchText = "baoyu" + store.matrixFilter = .claudeOnly + store.matrixTypeFilter = .skill + store.selectedSkillID = "skill:demo" + store.inspectorOpen = true + + store.showMatrix(filter: .brokenLinks) + + #expect(store.currentSelection == .matrix) + #expect(store.searchText == "") + #expect(store.matrixFilter == .brokenLinks) + #expect(store.matrixTypeFilter == .allTypes) + #expect(store.selectedSkillID == nil) + #expect(store.inspectorOpen == false) + + store.searchText = "keep" + store.showMatrix(typeFilter: .bundle, clearSearch: false) + + #expect(store.searchText == "keep") + #expect(store.matrixFilter == .all) + #expect(store.matrixTypeFilter == .bundle) + } + + @Test + func showSettingsSelectsSettingsWithoutChangingMatrixFilters() { + let store = PopskillStore() + store.currentSelection = .matrix + store.matrixFilter = .brokenLinks + store.matrixTypeFilter = .bundle + + store.showSettings() + + #expect(store.currentSelection == .settings) + #expect(store.matrixFilter == .brokenLinks) + #expect(store.matrixTypeFilter == .bundle) + } + + @Test + func matrixShortcutCountsUseCurrentCapabilities() { + let store = PopskillStore() + var claudeOnly = skillFixture( + id: "claude-only", + apps: SkillApps(claude: true, codex: false, gemini: false, opencode: false, hermes: false) + ) + claudeOnly.deployment = SkillDeployment( + strategy: "symlink", + ssotPath: "/Users/example/.cc-switch/skills/claude-only", + appLinks: [ + "claude": AppLinkStatus(path: "/Users/example/.claude/skills/claude-only", status: "broken") + ] + ) + store.skills = [ + claudeOnly, + skillFixture( + id: "codex-only", + apps: SkillApps(claude: false, codex: true, gemini: false, opencode: false, hermes: false) + ), + skillFixture( + id: "both", + apps: SkillApps(claude: true, codex: true, gemini: false, opencode: false, hermes: false) + ) + ] + store.localAgents = [ + LocalAgent( + id: "agent:demo", + name: "Demo Agent", + description: "Agent", + fileName: "demo.md", + path: "/tmp/demo.md", + category: "ops", + tools: [], + model: nil, + lastModifiedAt: nil, + sizeBytes: 100 + ) + ] + store.packages = [ + CapabilityPackage( + id: "pkg:empty", + type: .composite, + name: "Empty Bundle", + vendor: nil, + summary: "Bundle", + source: PackageSource(kind: "builtin", location: "empty", updateStrategy: "manual", repoOwner: nil, repoName: nil, repoBranch: nil, readmeUrl: nil), + components: PackageComponents(cli: [], skills: [], mcp: [], agents: []), + configSchema: [], + installed: false, + lifecycle: nil + ) + ] + + let counts = store.matrixShortcutCounts() + + #expect(counts.capabilityCount == 5) + #expect(counts.count(for: .claudeOnly) == 2) + #expect(counts.count(for: .codexOnly) == 1) + #expect(counts.count(for: .brokenLinks) == 1) + #expect(counts.count(for: .bundle) == 1) + #expect(counts.count(for: .skill) == 3) + #expect(counts.count(for: .agent) == 1) + #expect(store.matrixFilterCount(.claudeOnly) == counts.count(for: .claudeOnly)) + #expect(store.matrixTypeFilterCount(.bundle) == counts.count(for: .bundle)) + } + + @Test + func capabilitiesExposeCompositePackagesBeforeAtomicRows() { + let store = PopskillStore() + store.packages = [ + CapabilityPackage( + id: "pkg:demo", + type: .composite, + name: "Demo Bundle", + vendor: nil, + summary: "Demo bundle", + source: PackageSource( + kind: "builtin", + location: "demo", + updateStrategy: "manual", + repoOwner: nil, + repoName: nil, + repoBranch: nil, + readmeUrl: nil + ), + components: PackageComponents( + cli: [], + skills: [ + PackageComponent( + id: "demo-skill", + name: "Demo Skill", + kind: "skill", + required: true, + installed: true, + status: "installed", + location: "demo-skill" + ) + ], + mcp: [], + agents: [] + ), + configSchema: [], + installed: true, + lifecycle: nil + ), + CapabilityPackage( + id: "pkg:standalone", + type: .standalone, + name: "Standalone", + vendor: nil, + summary: "Standalone wrapper", + source: PackageSource(kind: "builtin", location: "standalone", updateStrategy: "manual", repoOwner: nil, repoName: nil, repoBranch: nil, readmeUrl: nil), + components: PackageComponents(cli: [], skills: [], mcp: [], agents: []), + configSchema: [], + installed: false, + lifecycle: nil + ) + ] + store.skills = [ + Skill( + id: "demo-skill", + name: "Demo Skill", + description: "Skill", + directory: "demo-skill", + repoOwner: nil, + repoName: nil, + readmeUrl: nil, + apps: SkillApps(claude: true, codex: false, gemini: false, opencode: false, hermes: false), + installedAt: nil, + updatedAt: nil, + contentHash: nil + ) + ] + + #expect(store.bundleCount == 1) + #expect(store.capabilities.map(\.kind) == [.bundle, .skill]) + #expect(store.capabilities.first?.appCoverage[.claude]?.label == "1/1") + } + + @Test + func bootstrapLoadsPersistedStubsFromSidecar() async throws { + let client = try fakeClient(stubbedAt: 123) + let store = PopskillStore(client: client) + + await store.bootstrap() + + #expect(store.stubs.map(\.id) == ["demo-stub"]) + #expect(store.stubs.first?.backupId == "backup-demo-stub") + #expect(store.errorMessage == nil) + } + + @Test + func upsertStubReplacesExistingSkillAndSortsNewestFirst() { + let store = PopskillStore() + + store.upsertStub(stubFixture(id: "older", stubbedAt: 10)) + store.upsertStub(stubFixture(id: "newer", stubbedAt: 30)) + store.upsertStub(stubFixture(id: "older", stubbedAt: 40)) + + #expect(store.stubs.map(\.id) == ["older", "newer"]) + #expect(store.stubs.map(\.stubbedAt) == [40, 30]) + } + + private func fakeClient(stubbedAt: Int) throws -> SkillCLIClient { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("PopskillStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let executable = root.appendingPathComponent("fake-skill-cli") + let script = """ + #!/bin/sh + case "$1" in + list|repo-list|agent-list|package-list) + printf '{"ok":true,"data":[]}' + ;; + stub-list) + cat <<'JSON' + {"ok":true,"data":[{"skill":{"id":"demo-stub","name":"Demo Stub","description":"Persisted stub","directory":"demo-stub","repo_owner":null,"repo_name":null,"readme_url":null,"apps":{"claude":false,"codex":false,"gemini":false,"opencode":false,"hermes":false},"installed_at":null,"updated_at":null,"content_hash":null},"backup_id":"backup-demo-stub","backup_path":"/tmp/backup-demo-stub","stubbed_at":\(stubbedAt)}]} + JSON + ;; + *) + printf '{"ok":false,"error":{"code":"UNKNOWN","message":"unexpected command"}}' + exit 1 + ;; + esac + """ + try script.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: executable.path + ) + return SkillCLIClient(executableURL: executable) + } + + private func stubFixture(id: String, stubbedAt: Int) -> StubbedSkill { + StubbedSkill( + skill: skillFixture( + id: id, + description: "Stub fixture", + apps: SkillApps(claude: false, codex: false, gemini: false, opencode: false, hermes: false) + ), + backupId: "backup-\(id)", + backupPath: "/tmp/backup-\(id)", + stubbedAt: stubbedAt + ) + } + + private func skillFixture( + id: String, + description: String = "Skill", + apps: SkillApps + ) -> Skill { + Skill( + id: id, + name: id, + description: description, + directory: id, + repoOwner: nil, + repoName: nil, + readmeUrl: nil, + apps: apps, + installedAt: nil, + updatedAt: nil, + contentHash: nil + ) + } } diff --git a/swift-app/Tests/PopskillTests/ReadmePreviewTests.swift b/swift-app/Tests/PopskillTests/ReadmePreviewTests.swift new file mode 100644 index 0000000..78801be --- /dev/null +++ b/swift-app/Tests/PopskillTests/ReadmePreviewTests.swift @@ -0,0 +1,57 @@ +@testable import Popskill +import Testing + +struct ReadmePreviewTests { + @Test + func excerptDropsYAMLFrontmatter() { + let content = """ + --- + name: demo-skill + description: Internal metadata + --- + + # Demo Skill + + Use this when the user asks for a demo. + """ + + let excerpt = ReadmePreview.makeExcerpt(from: content) + + #expect(excerpt.text.hasPrefix("# Demo Skill")) + #expect(!excerpt.text.contains("description: Internal metadata")) + #expect(excerpt.text.contains("Use this when")) + #expect(excerpt.truncated == false) + } + + @Test + func excerptCompactsBlankRunsAndTruncatesByLineCount() { + let content = """ + # Demo + + + One + + + Two + Three + Four + """ + + let excerpt = ReadmePreview.makeExcerpt(from: content, maxCharacters: 200, maxLines: 5) + + #expect(excerpt.text == "# Demo\n\nOne\n\nTwo") + #expect(excerpt.truncated) + } + + @Test + func excerptTruncatesByCharacterBudget() { + let excerpt = ReadmePreview.makeExcerpt( + from: "# Demo Skill\nThis line is longer than the budget.", + maxCharacters: 12, + maxLines: 10 + ) + + #expect(excerpt.text == "# Demo Skill") + #expect(excerpt.truncated) + } +} diff --git a/swift-app/Tests/PopskillTests/SettingsViewTests.swift b/swift-app/Tests/PopskillTests/SettingsViewTests.swift index ed11c48..45dc018 100644 --- a/swift-app/Tests/PopskillTests/SettingsViewTests.swift +++ b/swift-app/Tests/PopskillTests/SettingsViewTests.swift @@ -9,4 +9,61 @@ struct SettingsViewTests { #expect(!SyncProvider.webdav.actionable) #expect(!SyncProvider.none.actionable) } + + @Test + func syncSummaryUsesStderrAndExitCodeForCommandFailures() { + let result = SyncResult( + provider: "icloud", + action: "push", + ok: false, + exitCode: 23, + stdout: "", + stderr: "rsync: permission denied\n", + message: nil + ) + + let summary = result.summary(successMessage: "Done", emptyMessage: "No details") + + #expect(summary.state == .failure) + #expect(summary.message == "rsync: permission denied") + #expect(summary.details == [.exitCode(23)]) + } + + @Test + func syncSummarySurfacesICloudStatusEndpoints() { + let result = SyncResult( + provider: "icloud", + action: "status", + ok: true, + localPath: "/Users/me/.cc-switch/skills", + remotePath: "/Users/me/Library/Mobile Documents/com~apple~CloudDocs/Popskill/skills", + localCount: 8, + remoteCount: 6 + ) + + let summary = result.summary(successMessage: "Status checked", emptyMessage: "No details") + + #expect(summary.state == .success) + #expect(summary.message == "Status checked") + #expect(summary.details == [ + .localEndpoint(path: "/Users/me/.cc-switch/skills", count: 8), + .remoteEndpoint(path: "/Users/me/Library/Mobile Documents/com~apple~CloudDocs/Popskill/skills", count: 6) + ]) + } + + @Test + func syncSummaryTreatsUnimplementedProviderAsUnavailable() { + let result = SyncResult( + provider: "webdav", + action: "push", + message: "WebDAV sync is not available", + implemented: false + ) + + let summary = result.summary(successMessage: "Done", emptyMessage: "No details") + + #expect(summary.state == .unavailable) + #expect(summary.message == "WebDAV sync is not available") + #expect(summary.details.isEmpty) + } } diff --git a/swift-app/Tests/PopskillTests/SkillGroupingTests.swift b/swift-app/Tests/PopskillTests/SkillGroupingTests.swift index f2169b8..d92d1a3 100644 --- a/swift-app/Tests/PopskillTests/SkillGroupingTests.swift +++ b/swift-app/Tests/PopskillTests/SkillGroupingTests.swift @@ -85,13 +85,77 @@ struct SkillGroupingTests { @Test func sectionsRespectCanonicalKindOrder() { // CapabilityKind.allCases is the canonical order — even with inputs - // interleaved, sections come out in skill/agent/cli/mcp/config order. + // interleaved, sections come out in bundle/skill/agent/cli/mcp/config order. + let bundleCap = capability(name: "package", kind: .bundle, owner: "pkg", name2: "source") let agentCap = capability(name: "z", kind: .agent, owner: nil, name2: nil) let skillCap = capability(name: "y", kind: .skill, owner: "a", name2: "lib") - let sections = SkillGrouping.sections([agentCap, skillCap]) + let sections = SkillGrouping.sections([agentCap, skillCap, bundleCap]) - #expect(sections.map(\.kind) == [.skill, .agent]) + #expect(sections.map(\.kind) == [.bundle, .skill, .agent]) + } + + @Test + func sectionsCanReverseKindOrderForTypeAscendingSort() { + let bundleCap = capability(name: "package", kind: .bundle, owner: "pkg", name2: "source") + let agentCap = capability(name: "z", kind: .agent, owner: nil, name2: nil) + let skillCap = capability(name: "y", kind: .skill, owner: "a", name2: "lib") + + let sections = SkillGrouping.sections( + [agentCap, skillCap, bundleCap], + sort: .typeAscending + ) + + #expect(sections.map(\.kind) == [.agent, .skill, .bundle]) + } + + @Test + func groupSortsCapabilitiesByNameDescending() { + let alpha = capability(name: "Alpha", owner: "anthropics", name2: "skills") + let beta = capability(name: "beta", owner: "anthropics", name2: "skills") + let charlie = capability(name: "Charlie", owner: "anthropics", name2: "skills") + + let group = SkillGrouping.group([alpha, charlie, beta], sort: .nameDescending).first + + #expect(group?.capabilities.map(\.name) == ["Charlie", "beta", "Alpha"]) + } + + @Test + func groupSortsCapabilitiesByCallsDescending() { + let alpha = capability(name: "Alpha", owner: "anthropics", name2: "skills") + let beta = capability(name: "beta", owner: "anthropics", name2: "skills") + let usageIndex = MatrixUsageIndex( + summary: UsageSummary( + skillStats: [ + SkillUsageStat( + skillID: "Alpha", + sourcePlugin: nil, + usageEvents: 2, + inputTokens: 10, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: nil + ), + SkillUsageStat( + skillID: "beta", + sourcePlugin: nil, + usageEvents: 8, + inputTokens: 3, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: nil + ) + ] + ), + skills: [skill(id: "Alpha"), skill(id: "beta")], + packages: [] + ) + + let group = SkillGrouping.group([alpha, beta], sort: .callsDescending, usageIndex: usageIndex).first + + #expect(group?.capabilities.map(\.name) == ["beta", "Alpha"]) } @Test @@ -127,4 +191,20 @@ struct SkillGroupingTests { underlyingAgentID: kind == .agent ? name : nil ) } + + private func skill(id: String) -> Skill { + Skill( + id: id, + name: id, + description: "Test skill", + directory: id, + repoOwner: nil, + repoName: nil, + readmeUrl: nil, + apps: SkillApps(claude: false, codex: false, gemini: false, opencode: false, hermes: false), + installedAt: nil, + updatedAt: nil, + contentHash: nil + ) + } } diff --git a/swift-app/Tests/PopskillTests/SkillModelsTests.swift b/swift-app/Tests/PopskillTests/SkillModelsTests.swift index 34b6693..04230b2 100644 --- a/swift-app/Tests/PopskillTests/SkillModelsTests.swift +++ b/swift-app/Tests/PopskillTests/SkillModelsTests.swift @@ -109,6 +109,91 @@ struct SkillModelsTests { #expect(skill.sourceLabel == "demo") } + @Test + func installedSkillDecodesManifestMetadata() throws { + let data = Data(""" + { + "id": "baoyu-comic", + "name": "baoyu-comic", + "description": "Create knowledge comics.", + "directory": "baoyu-comic", + "repo_owner": "JimLiu", + "repo_name": "baoyu-skills", + "readme_url": null, + "apps": { + "claude": true, + "codex": true, + "gemini": false, + "opencode": false, + "hermes": false + }, + "installed_at": 1, + "updated_at": 2, + "content_hash": "abcdef123456", + "manifest": { + "version": "1.56.1", + "author": "@dotey", + "license": "MIT", + "homepage": "https://github.com/JimLiu/baoyu-skills#baoyu-comic", + "required_bins": ["bun", "npx"], + "required_tools": [ + {"name": "bun", "available": true}, + {"name": "npx", "available": false} + ] + } + } + """.utf8) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let skill = try decoder.decode(Skill.self, from: data) + + #expect(skill.manifest?.semanticVersion == "1.56.1") + #expect(skill.manifest?.author == "@dotey") + #expect(skill.manifest?.license == "MIT") + #expect(skill.manifest?.requiredBinsLabel == "bun, npx") + #expect(skill.manifest?.hasMissingRequiredTools == true) + #expect(skill.manifest?.requiredToolsLabel(availableLabel: "ok", missingLabel: "missing") == "bun ok · npx missing") + #expect(skill.sourceURL?.absoluteString == "https://github.com/JimLiu/baoyu-skills#baoyu-comic") + } + + @Test + func installedSkillDecodesManifestWithoutRequiredBins() throws { + let data = Data(""" + { + "id": "demo", + "name": "Demo", + "description": "Demo skill", + "directory": "demo", + "repo_owner": null, + "repo_name": null, + "readme_url": null, + "apps": { + "claude": true, + "codex": false, + "gemini": false, + "opencode": false, + "hermes": false + }, + "installed_at": null, + "updated_at": null, + "content_hash": null, + "manifest": { + "version": "2.0.0" + } + } + """.utf8) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let skill = try decoder.decode(Skill.self, from: data) + + #expect(skill.manifest?.semanticVersion == "2.0.0") + #expect(skill.manifest?.requiredBins == []) + #expect(skill.manifest?.requiredTools == []) + #expect(skill.manifest?.requiredToolsLabel(availableLabel: "ok", missingLabel: "missing") == nil) + } + @Test func installedSkillLocalStoreURLUsesCCSwitchStore() { let skill = installedSkill(directory: "demo-skill") @@ -154,6 +239,189 @@ struct SkillModelsTests { #expect(skill.enabledAppCount == 3) } + @Test + func skillBrokenLinkDetectionUsesDeploymentStatuses() { + var skill = installedSkill(directory: "demo-skill") + + #expect(skill.hasBrokenLink == false) + + skill.deployment = SkillDeployment( + strategy: "symlink", + ssotPath: "/Users/example/.cc-switch/skills/demo-skill", + appLinks: [ + "claude": AppLinkStatus(path: "/Users/example/.claude/skills/demo-skill", status: "ok"), + "codex": AppLinkStatus(path: "/Users/example/.codex/skills/demo-skill", status: "BROKEN") + ] + ) + + #expect(skill.deployment?.hasBrokenLink == true) + #expect(skill.hasBrokenLink == true) + } + + @Test + func capabilityPackageBrokenLinksAggregateMatchedSkills() { + let package = self.package(components: [component(id: "demo-skill", installed: true)]) + let unrelatedPackage = self.package(components: [component(id: "healthy-skill", installed: true)]) + var skill = installedSkill(directory: "demo-skill") + + skill.deployment = SkillDeployment( + strategy: "symlink", + ssotPath: "/Users/example/.cc-switch/skills/demo-skill", + appLinks: [ + "claude": AppLinkStatus(path: "/Users/example/.claude/skills/demo-skill", status: "broken") + ] + ) + + #expect(package.hasBrokenLinks(in: [skill]) == true) + #expect(unrelatedPackage.hasBrokenLinks(in: [skill]) == false) + } + + @Test + func capabilityPackageLinkHealthSnapshotMatchesInstalledComponents() { + let package = self.package(components: [ + component(id: "demo-skill", installed: true), + component(id: "second-skill", installed: true) + ]) + let demo = installedSkill(directory: "owner/repo:demo-skill") + let second = installedSkill(directory: "second-skill") + let report = LinkHealthReport( + summary: LinkHealthSummary(ok: 5, broken: 3, inactive: 2), + rows: [ + LinkHealthRow( + skillId: "owner/repo:demo-skill", + skillName: "Demo", + deployment: SkillDeployment( + strategy: "symlink", + ssotPath: "/Users/example/.cc-switch/skills/demo-skill", + appLinks: [ + "claude": AppLinkStatus(path: "/Users/example/.claude/skills/demo-skill", status: "ok"), + "codex": AppLinkStatus(path: "/Users/example/.codex/skills/demo-skill", status: "broken") + ] + ) + ), + LinkHealthRow( + skillId: "second-skill", + skillName: "Second", + deployment: SkillDeployment( + strategy: "symlink", + ssotPath: "/Users/example/.cc-switch/skills/second-skill", + appLinks: [ + "claude": AppLinkStatus(path: "/Users/example/.claude/skills/second-skill", status: "inactive") + ] + ) + ), + LinkHealthRow( + skillId: "unrelated", + skillName: "Unrelated", + deployment: SkillDeployment( + strategy: "symlink", + ssotPath: "/Users/example/.cc-switch/skills/unrelated", + appLinks: [ + "claude": AppLinkStatus(path: "/Users/example/.claude/skills/unrelated", status: "ok") + ] + ) + ) + ] + ) + + let snapshot = package.linkHealthSnapshot(using: report, skills: [demo, second]) + + #expect(snapshot?.rows.map(\.skillId) == ["owner/repo:demo-skill", "second-skill"]) + #expect(snapshot?.okCount == 1) + #expect(snapshot?.brokenCount == 1) + #expect(snapshot?.inactiveCount == 1) + #expect(package.linkHealthSnapshot(using: nil, skills: [demo]) == nil) + } + + @Test + func matrixCapabilityBrokenLinksAggregateDirectAndPackageLinks() { + let package = self.package(components: [component(id: "demo-skill", installed: true)]) + let unrelatedPackage = self.package(components: [component(id: "healthy-skill", installed: true)]) + var skill = installedSkill(directory: "demo-skill") + + skill.deployment = SkillDeployment( + strategy: "symlink", + ssotPath: "/Users/example/.cc-switch/skills/demo-skill", + appLinks: [ + "codex": AppLinkStatus(path: "/Users/example/.codex/skills/demo-skill", status: "broken") + ] + ) + + #expect(MatrixCapability.fromSkill(skill).hasBrokenLinks(in: []) == true) + #expect(MatrixCapability.fromPackage(package, skills: [skill]).hasBrokenLinks(in: [skill]) == true) + #expect(MatrixCapability.fromPackage(unrelatedPackage, skills: [skill]).hasBrokenLinks(in: [skill]) == false) + } + + @Test + func matrixCapabilitySearchMatchesSourceTriggersAndAppTargets() { + var skill = Skill( + id: "dotey/prompt-engineering:baoyu-comic", + name: "baoyu-comic", + description: "Comic generator", + directory: "baoyu-comic", + repoOwner: "dotey", + repoName: "prompt-engineering", + readmeUrl: nil, + apps: SkillApps(claude: false, codex: true, gemini: false, opencode: false, hermes: false), + installedAt: nil, + updatedAt: nil, + contentHash: nil + ) + skill.capabilitySummary = "Turns topics into four-panel comics." + skill.triggerScenarios = ["用 baoyu-comic 把 X 画成四格"] + skill.sourceType = "github" + let capability = MatrixCapability.fromSkill(skill) + + #expect(capability.matchesSearch(query: SearchTextNormalizer.key("dotey"))) + #expect(capability.matchesSearch(query: SearchTextNormalizer.key("prompt engineering"))) + #expect(capability.matchesSearch(query: SearchTextNormalizer.key("四格"))) + #expect(capability.matchesSearch(query: SearchTextNormalizer.key("codex"))) + #expect(!capability.matchesSearch(query: SearchTextNormalizer.key("feishu"))) + } + + @Test + func matrixCapabilitySearchMatchesPackageSourceComponentsAndConfig() { + let package = CapabilityPackage( + id: "pkg:feishu-suite", + type: .composite, + name: "Feishu Suite", + vendor: "ByteDance", + summary: "Office automation suite", + source: PackageSource( + kind: "github", + location: "github.com/feishu/lark-suite", + updateStrategy: "manual", + repoOwner: "feishu", + repoName: "lark-suite", + repoBranch: "main", + readmeUrl: nil + ), + components: PackageComponents( + cli: [ + PackageComponent(id: "lark-cli", name: "lark-cli", kind: "cli", required: true, installed: false, status: "stub", location: "feishu-suite/lark-cli") + ], + skills: [], + mcp: [ + PackageComponent(id: "lark-openapi-mcp", name: "Lark OpenAPI MCP", kind: "mcp", required: false, installed: false, status: "registry-reference", location: nil) + ], + agents: [] + ), + configSchema: [ + PackageConfigField(id: "lark.app_secret", label: "App Secret", required: true, secret: true, storage: "keychain") + ], + installed: false, + lifecycle: nil + ) + let capability = MatrixCapability.fromPackage(package, skills: []) + + #expect(capability.matchesSearch(query: SearchTextNormalizer.key("bytedance"))) + #expect(capability.matchesSearch(query: SearchTextNormalizer.key("lark suite"))) + #expect(capability.matchesSearch(query: SearchTextNormalizer.key("套装"))) + #expect(capability.matchesSearch(query: SearchTextNormalizer.key("占位"))) + #expect(capability.matchesSearch(query: SearchTextNormalizer.key("keychain"))) + #expect(!capability.matchesSearch(query: SearchTextNormalizer.key("baoyu comic"))) + } + @Test func targetAppRegistryCoversCurrentSkillTargets() { #expect(TargetAppRegistry.all.map(\.app) == TargetApp.supported) @@ -225,6 +493,124 @@ struct SkillModelsTests { #expect(!skill.matchesAttributionSkill("baoyu-cover-image")) } + @Test + func installedSkillUsageSnapshotAggregatesAttributionStats() { + let skill = installedSkill(directory: "baoyu-comic") + let lastUsed = Date(timeIntervalSince1970: 1_800_000_000) + let summary = UsageSummary( + filesScanned: 2, + sessions: 2, + usageEvents: 3, + inputTokens: 100, + outputTokens: 80, + cacheCreationTokens: 0, + cacheReadTokens: 0, + attributedSkillUsageEvents: 2, + modelStats: [], + skillStats: [ + SkillUsageStat( + skillID: "baoyu-comic", + sourcePlugin: nil, + usageEvents: 2, + inputTokens: 20, + outputTokens: 30, + cacheCreationTokens: 5, + cacheReadTokens: 7, + lastUsedAt: lastUsed + ), + SkillUsageStat( + skillID: "jimliu/baoyu-skills:baoyu-comic", + sourcePlugin: nil, + usageEvents: 1, + inputTokens: 10, + outputTokens: 12, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: Date(timeIntervalSince1970: 1_700_000_000) + ), + SkillUsageStat( + skillID: "unrelated", + sourcePlugin: nil, + usageEvents: 9, + inputTokens: 999, + outputTokens: 999, + cacheCreationTokens: 999, + cacheReadTokens: 999, + lastUsedAt: nil + ) + ], + recentSessions: [] + ) + + let snapshot = skill.usageSnapshot(using: summary) + + #expect(snapshot?.usageEvents == 3) + #expect(snapshot?.totalTokens == 84) + #expect(snapshot?.lastUsedAt == lastUsed) + } + + @Test + func installedSkillUsageSnapshotPrefersRecentThirtyDayWindow() { + let skill = installedSkill(directory: "baoyu-comic") + let oldStat = SkillUsageStat( + skillID: "baoyu-comic", + sourcePlugin: nil, + usageEvents: 9, + inputTokens: 90, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let recentDay = Date(timeIntervalSince1970: 1_799_971_200) + var recentStat = SkillUsageStat( + skillID: "baoyu-comic", + sourcePlugin: nil, + usageEvents: 2, + inputTokens: 4, + outputTokens: 6, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + recentStat.dailyStats = [ + UsageBucketStat( + dayStart: recentDay, + usageEvents: 2, + inputTokens: 4, + outputTokens: 6, + cacheCreationTokens: 0, + cacheReadTokens: 0 + ) + ] + var recentWindow = UsageWindowSummary( + days: 30, + startedAt: Date(timeIntervalSince1970: 1_799_000_000), + endedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + recentWindow.usageEvents = 2 + recentWindow.inputTokens = 4 + recentWindow.outputTokens = 6 + recentWindow.attributedSkillUsageEvents = 2 + recentWindow.skillStats = [recentStat] + let summary = UsageSummary( + usageEvents: 11, + inputTokens: 94, + outputTokens: 6, + attributedSkillUsageEvents: 11, + skillStats: [oldStat], + recent30Days: recentWindow + ) + + let snapshot = skill.usageSnapshot(using: summary) + + #expect(snapshot?.usageEvents == 2) + #expect(snapshot?.totalTokens == 10) + #expect(snapshot?.lastUsedAt == recentStat.lastUsedAt) + #expect(snapshot?.dailyStats.map(\.dayStart) == [recentDay]) + #expect(snapshot?.dailyStats.first?.usageEvents == 2) + } + @Test func installPlanDecodesPreviewPayload() throws { let data = """ @@ -639,6 +1025,516 @@ struct SkillModelsTests { #expect(package.components.all.map(\.displayKey) == ["cli:lark-cli", "skill:lark-doc"]) } + @Test + func packageComponentCompositionSummarizesKindsInStableOrder() { + let package = CapabilityPackage( + id: "pkg:lark", + type: .composite, + name: "Feishu / Lark", + vendor: "ByteDance", + summary: "Collaboration suite.", + source: PackageSource( + kind: "builtin", + location: "popskill/builtin/lark", + updateStrategy: "manual", + repoOwner: nil, + repoName: nil, + repoBranch: nil, + readmeUrl: nil + ), + components: PackageComponents( + cli: [ + PackageComponent(id: "lark-cli", name: "lark-cli", kind: "cli", required: true, installed: true, status: "detected", location: nil) + ], + skills: [ + PackageComponent(id: "lark-doc", name: "Lark Doc", kind: "skill", required: true, installed: true, status: "installed", location: "lark-doc"), + PackageComponent(id: "lark-base", name: "Lark Base", kind: "skill", required: false, installed: true, status: "installed", location: "lark-base") + ], + mcp: [ + PackageComponent(id: "lark-mcp", name: "Lark MCP", kind: "mcp", required: false, installed: false, status: "registry-reference", location: nil) + ], + agents: [ + PackageComponent(id: "lark-agent", name: "Lark Agent", kind: "agent", required: false, installed: true, status: "installed", location: "~/.claude/agents/lark-agent.md") + ] + ), + configSchema: [], + installed: true, + lifecycle: nil + ) + + #expect(package.componentCompositionCounts.map { "\($0.kind):\($0.count)" } == ["cli:1", "mcp:1", "skill:2", "agent:1"]) + #expect(PackageComponentCompositionFormatter.composition(for: package, localization: PopskillLocalization(language: .english)) == "1 CLI + 1 MCP + 2 Skills + 1 Agent") + #expect(PackageComponentCompositionFormatter.composition(for: package, localization: PopskillLocalization(language: .simplifiedChinese)) == "1 个 CLI + 1 个 MCP + 2 项 Skill + 1 个 Agent") + #expect(PackageComponentCompositionFormatter.summary(for: package, localization: PopskillLocalization(language: .english)) == "Collaboration suite. · 1 CLI + 1 MCP + 2 Skills + 1 Agent") + } + + @Test + func packageComponentMatchesInstalledSkillByScopedIdentifierAndLocation() { + let component = PackageComponent( + id: "lark-doc", + name: "Lark Doc", + kind: "skill", + required: true, + installed: true, + status: "installed", + location: "skills/lark-doc" + ) + let skill = Skill( + id: "larksuite/cli:lark-doc", + name: "Lark Doc", + description: "Docs skill", + directory: "lark-doc", + repoOwner: "larksuite", + repoName: "cli", + readmeUrl: nil, + apps: SkillApps(claude: true, codex: false, gemini: false, opencode: false, hermes: false), + installedAt: nil, + updatedAt: nil, + contentHash: nil + ) + + #expect(component.matchesSkill(skill)) + } + + @Test + func capabilityPackageCoverageUsesSkillTogglesAndComponentFallbacks() { + let package = CapabilityPackage( + id: "pkg:lark", + type: .composite, + name: "Feishu / Lark", + vendor: "ByteDance", + summary: "Composite office package.", + source: PackageSource( + kind: "builtin", + location: "popskill/builtin/lark", + updateStrategy: "manual", + repoOwner: nil, + repoName: nil, + repoBranch: nil, + readmeUrl: nil + ), + components: PackageComponents( + cli: [ + PackageComponent(id: "lark-cli", name: "lark-cli", kind: "cli", required: true, installed: true, status: "detected", location: nil) + ], + skills: [ + PackageComponent(id: "lark-doc", name: "Lark Doc", kind: "skill", required: true, installed: true, status: "installed", location: "lark-doc") + ], + mcp: [ + PackageComponent(id: "lark-mcp", name: "Lark MCP", kind: "mcp", required: false, installed: false, status: "registry-reference", location: nil) + ], + agents: [ + PackageComponent(id: "lark-agent", name: "Lark Agent", kind: "agent", required: false, installed: true, status: "installed", location: "~/.claude/agents/lark-agent.md") + ] + ), + configSchema: [], + installed: true, + lifecycle: nil + ) + let skill = Skill( + id: "larksuite/cli:lark-doc", + name: "Lark Doc", + description: "Docs skill", + directory: "lark-doc", + repoOwner: "larksuite", + repoName: "cli", + readmeUrl: nil, + apps: SkillApps(claude: true, codex: false, gemini: false, opencode: false, hermes: false), + installedAt: nil, + updatedAt: nil, + contentHash: nil + ) + + let coverage = package.appCoverage(using: [skill]) + let capability = MatrixCapability.fromPackage(package, skills: [skill]) + + #expect(coverage[.claude]?.label == "3/4") + #expect(coverage[.codex]?.label == "1/4") + #expect(capability.id == "bundle:pkg:lark") + #expect(capability.kind == .bundle) + #expect(capability.apps.claude == true) + #expect(capability.apps.codex == true) + } + + @Test + func packageComponentAppStateUsesInstalledSkillTogglesAndStubStatus() { + let skill = installedSkill( + directory: "baoyu-comic", + apps: SkillApps(claude: true, codex: false, gemini: false, opencode: false, hermes: false) + ) + let skillComponent = PackageComponent( + id: "baoyu-comic", + name: "baoyu-comic", + kind: "skill", + required: true, + installed: true, + status: "installed", + location: "skills/baoyu-comic" + ) + let stubbedAgent = PackageComponent( + id: "base-analyst", + name: "base-analyst", + kind: "agent", + required: false, + installed: false, + status: "stub", + location: "~/.claude/agents/base-analyst.md" + ) + let installedCLI = PackageComponent( + id: "lark-cli", + name: "lark-cli", + kind: "cli", + required: true, + installed: true, + status: "detected", + location: nil + ) + + #expect(skillComponent.appState(for: .claude, matching: skill) == .active) + #expect(skillComponent.appState(for: .codex, matching: skill) == .off) + #expect(stubbedAgent.appState(for: .claude, matching: nil) == .stub) + #expect(stubbedAgent.appState(for: .codex, matching: nil) == .off) + #expect(installedCLI.appState(for: .codex, matching: nil) == .active) + #expect(installedCLI.appState(for: .gemini, matching: nil) == .unsupported) + } + + @Test + func capabilityPackageCoverageBreakdownCountsStubbedAndOffComponents() { + let skill = installedSkill( + directory: "baoyu-comic", + apps: SkillApps(claude: true, codex: false, gemini: false, opencode: false, hermes: false) + ) + let package = self.package(components: [ + PackageComponent( + id: "baoyu-comic", + name: "baoyu-comic", + kind: "skill", + required: true, + installed: true, + status: "installed", + location: "skills/baoyu-comic" + ), + PackageComponent( + id: "base-analyst", + name: "base-analyst", + kind: "agent", + required: false, + installed: false, + status: "stub", + location: "~/.claude/agents/base-analyst.md" + ), + PackageComponent( + id: "lark-cli", + name: "lark-cli", + kind: "cli", + required: true, + installed: true, + status: "detected", + location: nil + ) + ]) + + let claude = package.appCoverageBreakdown(for: .claude, skills: [skill]) + let codex = package.appCoverageBreakdown(for: .codex, skills: [skill]) + + #expect(claude.label == "2/3") + #expect(claude.percent == 67) + #expect(claude.enabled == 2) + #expect(claude.stubbed == 1) + #expect(claude.off == 0) + #expect(codex.label == "1/3") + #expect(codex.percent == 33) + #expect(codex.enabled == 1) + #expect(codex.stubbed == 0) + #expect(codex.off == 2) + #expect(package.appCoverage(using: [skill])[.claude]?.label == claude.label) + } + + @Test + func capabilityPackageUsageSnapshotAggregatesMatchedSkillStats() { + let package = CapabilityPackage( + id: "pkg:baoyu", + type: .composite, + name: "Baoyu Skills", + vendor: "@dotey", + summary: "Prompt package", + source: PackageSource( + kind: "github", + location: "jimliu/baoyu-skills", + updateStrategy: "git", + repoOwner: "jimliu", + repoName: "baoyu-skills", + repoBranch: nil, + readmeUrl: nil + ), + components: PackageComponents( + cli: [], + skills: [ + PackageComponent(id: "baoyu-comic", name: "baoyu-comic", kind: "skill", required: true, installed: true, status: "installed", location: "skills/baoyu-comic"), + PackageComponent(id: "baoyu-translate", name: "baoyu-translate", kind: "skill", required: true, installed: true, status: "installed", location: "skills/baoyu-translate") + ], + mcp: [], + agents: [] + ), + configSchema: [], + installed: true, + lifecycle: nil + ) + let comic = Skill( + id: "jimliu/baoyu-skills:baoyu-comic", + name: "baoyu-comic", + description: "Comic", + directory: "baoyu-comic", + repoOwner: "jimliu", + repoName: "baoyu-skills", + readmeUrl: nil, + apps: SkillApps(claude: true, codex: true, gemini: false, opencode: false, hermes: false), + installedAt: nil, + updatedAt: nil, + contentHash: nil + ) + let lastUsed = Date(timeIntervalSince1970: 1_800_000_000) + let summary = UsageSummary( + filesScanned: 3, + sessions: 2, + usageEvents: 4, + inputTokens: 100, + outputTokens: 80, + cacheCreationTokens: 0, + cacheReadTokens: 0, + attributedSkillUsageEvents: 3, + modelStats: [], + skillStats: [ + SkillUsageStat( + skillID: "jimliu/baoyu-skills:baoyu-comic", + sourcePlugin: nil, + usageEvents: 2, + inputTokens: 20, + outputTokens: 30, + cacheCreationTokens: 5, + cacheReadTokens: 7, + lastUsedAt: lastUsed + ), + SkillUsageStat( + skillID: "other:baoyu-translate", + sourcePlugin: nil, + usageEvents: 1, + inputTokens: 10, + outputTokens: 12, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: Date(timeIntervalSince1970: 1_700_000_000) + ), + SkillUsageStat( + skillID: "unrelated", + sourcePlugin: nil, + usageEvents: 9, + inputTokens: 999, + outputTokens: 999, + cacheCreationTokens: 999, + cacheReadTokens: 999, + lastUsedAt: nil + ) + ], + recentSessions: [] + ) + + let snapshot = package.usageSnapshot(using: summary, skills: [comic]) + + #expect(snapshot?.matchedSkillCount == 2) + #expect(snapshot?.usageEvents == 3) + #expect(snapshot?.totalTokens == 84) + #expect(snapshot?.lastUsedAt == lastUsed) + #expect(snapshot?.componentStats.map(\.componentID) == ["baoyu-comic", "baoyu-translate"]) + #expect(snapshot?.componentStats.first?.componentName == "baoyu-comic") + #expect(snapshot?.componentStats.first?.usageEvents == 2) + #expect(snapshot?.componentStats.first?.totalTokens == 62) + } + + @Test + func capabilityPackageInstalledSizeBytesAggregatesMatchedSkills() { + let package = self.package(components: [ + component(id: "baoyu-comic", installed: true), + component(id: "baoyu-translate", installed: true) + ]) + let comic = installedSkill(directory: "baoyu-comic", sizeBytes: 1_024) + let translate = installedSkill(directory: "baoyu-translate", sizeBytes: 2_048) + let unrelated = installedSkill(directory: "unrelated", sizeBytes: 8_192) + + #expect(package.installedSizeBytes(in: [comic, translate, unrelated]) == 3_072) + #expect(package.installedSizeBytes(in: [installedSkill(directory: "baoyu-comic")]) == nil) + } + + @Test + func capabilityPackageComponentVersionSummariesPreferSemanticVersionThenHash() { + let package = self.package(components: [ + component(id: "versioned-skill", installed: true), + component(id: "hashed-skill", installed: true), + component(id: "missing-skill", installed: false) + ]) + let versioned = installedSkill( + directory: "versioned-skill", + contentHash: "abcdef123456", + manifest: SkillManifest(version: "2.4.1") + ) + let hashed = installedSkill( + directory: "hashed-skill", + updatedAt: 1_700_000_000, + contentHash: "1234567890" + ) + + let summaries = package.componentVersionSummaries(in: [versioned, hashed]) + + #expect(summaries.map(\.name) == ["versioned-skill", "hashed-skill", "missing-skill"]) + #expect(summaries[0].versionLabel == "v2.4.1") + #expect(summaries[1].versionLabel == "1234567") + #expect(summaries[2].versionLabel == nil) + #expect(package.componentVersionLabel(for: package.components.skills[0], in: [versioned]) == "v2.4.1") + } + + @Test + func capabilityPackageUsageSnapshotPrefersRecentThirtyDayWindow() { + let package = self.package(components: [component(id: "baoyu-comic", installed: true)]) + let skill = installedSkill(directory: "baoyu-comic") + let oldStat = SkillUsageStat( + skillID: "baoyu-comic", + sourcePlugin: nil, + usageEvents: 6, + inputTokens: 60, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: nil + ) + let recentDay = Date(timeIntervalSince1970: 1_799_971_200) + var recentStat = SkillUsageStat( + skillID: "baoyu-comic", + sourcePlugin: nil, + usageEvents: 1, + inputTokens: 2, + outputTokens: 3, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + recentStat.dailyStats = [ + UsageBucketStat( + dayStart: recentDay, + usageEvents: 1, + inputTokens: 2, + outputTokens: 3, + cacheCreationTokens: 0, + cacheReadTokens: 0 + ) + ] + var recentWindow = UsageWindowSummary( + days: 30, + startedAt: Date(timeIntervalSince1970: 1_799_000_000), + endedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + recentWindow.usageEvents = 1 + recentWindow.inputTokens = 2 + recentWindow.outputTokens = 3 + recentWindow.attributedSkillUsageEvents = 1 + recentWindow.skillStats = [recentStat] + let summary = UsageSummary( + usageEvents: 7, + inputTokens: 62, + outputTokens: 3, + attributedSkillUsageEvents: 7, + skillStats: [oldStat], + recent30Days: recentWindow + ) + + let snapshot = package.usageSnapshot(using: summary, skills: [skill]) + + #expect(snapshot?.usageEvents == 1) + #expect(snapshot?.totalTokens == 5) + #expect(snapshot?.componentStats.first?.componentID == "baoyu-comic") + #expect(snapshot?.dailyStats.map(\.dayStart) == [recentDay]) + #expect(snapshot?.componentStats.first?.dailyStats.map(\.usageEvents) == [1]) + } + + @Test + func capabilityPackageFindsContainedAndCompanionSkills() { + let package = self.package(components: [ + component(id: "baoyu-comic", installed: true), + component(id: "baoyu-translate", installed: true), + component(id: "declared-only", installed: false) + ]) + let comic = installedSkill(directory: "baoyu-comic") + let translate = installedSkill(directory: "baoyu-translate") + let unrelated = installedSkill(directory: "other-skill") + + #expect(package.containsSkill(comic)) + #expect(package.containsSkill(translate)) + #expect(!package.containsSkill(unrelated)) + #expect(package.companionInstalledSkills(for: comic, in: [comic, translate, unrelated]).map(\.id) == ["baoyu-translate"]) + #expect(package.installedSkillsRequiringEnablement(for: .claude, in: [comic, translate, unrelated]).isEmpty) + #expect(package.installedSkillsRequiringEnablement(for: .codex, in: [comic, translate, unrelated]).map(\.id) == ["baoyu-comic", "baoyu-translate"]) + } + + @Test + func matrixUsageIndexCachesSkillPackageAndComponentUsage() { + let skill = installedSkill(directory: "baoyu-comic") + let package = self.package(components: [ + component(id: "baoyu-comic", installed: true) + ]) + let summary = UsageSummary( + filesScanned: 1, + sessions: 1, + usageEvents: 3, + inputTokens: 50, + outputTokens: 30, + cacheCreationTokens: 0, + cacheReadTokens: 0, + attributedSkillUsageEvents: 2, + modelStats: [], + skillStats: [ + SkillUsageStat( + skillID: "baoyu-comic", + sourcePlugin: nil, + usageEvents: 2, + inputTokens: 20, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + lastUsedAt: nil + ), + SkillUsageStat( + skillID: "unrelated", + sourcePlugin: nil, + usageEvents: 1, + inputTokens: 99, + outputTokens: 99, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: nil + ) + ], + recentSessions: [] + ) + + let index = MatrixUsageIndex(summary: summary, skills: [skill], packages: [package]) + let emptyIndex = MatrixUsageIndex(summary: nil, skills: [skill], packages: [package]) + + #expect(index.hasSummary) + #expect(index.skillSnapshot(for: "baoyu-comic")?.usageEvents == 2) + #expect(index.skillSnapshot(for: "baoyu-comic")?.totalTokens == 35) + #expect(index.packageSnapshot(for: "pkg:demo")?.usageEvents == 2) + #expect(index.packageComponentStat(packageID: "pkg:demo", componentID: "baoyu-comic")?.totalTokens == 35) + #expect(index.skillSnapshot(for: "missing")?.hasUsage == false) + #expect(!emptyIndex.hasSummary) + #expect(emptyIndex.skillSnapshot(for: "baoyu-comic") == nil) + } + + @Test + func usageDisplayFormatterCompactsMatrixMetrics() { + #expect(UsageDisplayFormatter.compactTokens(999) == "999") + #expect(UsageDisplayFormatter.compactTokens(1_500) == "1.5K") + #expect(UsageDisplayFormatter.compactCount(2_500_000) == "2.5M") + } + @Test func capabilityPackageHealthSeparatesActivePartialBlockedAndInactive() { #expect(package(components: []).health == .inactive) @@ -829,6 +1725,15 @@ struct SkillModelsTests { #expect(package.trackedContentHash == nil) } + @Test + func matrixVersionFormatterPrefersHashThenUpdatedDate() { + #expect(MatrixVersionFormatter.value(manifestVersion: "2.4.1", contentHash: "abcdef123456", updatedAt: 1_700_000_000) == "v2.4.1") + #expect(MatrixVersionFormatter.value(manifestVersion: " v3.0.0 ", contentHash: nil, updatedAt: nil) == "v3.0.0") + #expect(MatrixVersionFormatter.value(contentHash: " abcdef123456 ", updatedAt: 1_700_000_000) == "abcdef1") + #expect(MatrixVersionFormatter.value(contentHash: nil, updatedAt: 1_700_000_000) == "2023-11-14") + #expect(MatrixVersionFormatter.value(contentHash: " ", updatedAt: 0) == nil) + } + @Test func capabilityPackageMatchesScopedSkillUpdateIdentifier() { let package = self.package( @@ -969,6 +1874,9 @@ struct SkillModelsTests { directory: String, installedAt: Int? = nil, updatedAt: Int? = nil, + contentHash: String? = nil, + sizeBytes: UInt64? = nil, + manifest: SkillManifest? = nil, apps: SkillApps = SkillApps( claude: true, codex: false, @@ -977,7 +1885,7 @@ struct SkillModelsTests { hermes: false ) ) -> Skill { - Skill( + var skill = Skill( id: directory, name: "Demo", description: "Demo skill", @@ -988,8 +1896,11 @@ struct SkillModelsTests { apps: apps, installedAt: installedAt, updatedAt: updatedAt, - contentHash: nil + contentHash: contentHash, + sizeBytes: sizeBytes ) + skill.manifest = manifest + return skill } private func package(components: [PackageComponent]) -> CapabilityPackage { diff --git a/swift-app/Tests/PopskillTests/SkillSearchScorerTests.swift b/swift-app/Tests/PopskillTests/SkillSearchScorerTests.swift index ab37672..b1c07ad 100644 --- a/swift-app/Tests/PopskillTests/SkillSearchScorerTests.swift +++ b/swift-app/Tests/PopskillTests/SkillSearchScorerTests.swift @@ -30,6 +30,22 @@ struct SkillSearchScorerTests { #expect((prefix?.score ?? 0) > (contains?.score ?? 0)) } + @Test + func spaceSeparatedQueryMatchesDashedSkillName() { + let hit = SkillSearchScorer.score(skill: skill(name: "baoyu-comic"), query: "baoyu comic") + + #expect(hit?.matchedOnName == true) + #expect((hit?.score ?? 0) >= 1000) + } + + @Test + func compactQueryMatchesSeparatedSkillName() { + let hit = SkillSearchScorer.score(skill: skill(name: "baoyu comic"), query: "baoyucomic") + + #expect(hit?.matchedOnName == true) + #expect((hit?.score ?? 0) >= 1000) + } + @Test func triggerMatchesAddOneHundredEach() { let hit = SkillSearchScorer.score( @@ -89,8 +105,9 @@ struct SkillSearchScorerTests { ), query: "画图" ) - #expect((hit?.matchedTriggers ?? []).contains("画图")) - #expect((hit?.matchedTriggers ?? []).contains("画个图")) + let matchedTriggers = hit?.matchedTriggers ?? [] + #expect(matchedTriggers.contains("画图") == true) + #expect(matchedTriggers.contains("画个图") == true) } @Test @@ -109,6 +126,17 @@ struct SkillSearchScorerTests { #expect(hit?.matchedOnName == false) } + @Test + func agentQueryMatchesDelimitedFileName() { + let hit = SkillSearchScorer.score( + agent: localAgent(name: "lark-office-assistant", description: "Drafts documents"), + query: "office assistant" + ) + + #expect(hit?.matchedOnName == true) + #expect((hit?.score ?? 0) >= 200) + } + @Test func agentScoringCombinesNameTriggerAndSummary() { let hit = SkillSearchScorer.score( diff --git a/swift-app/Tests/PopskillTests/SpotlightActionTests.swift b/swift-app/Tests/PopskillTests/SpotlightActionTests.swift new file mode 100644 index 0000000..098ef2a --- /dev/null +++ b/swift-app/Tests/PopskillTests/SpotlightActionTests.swift @@ -0,0 +1,204 @@ +@testable import Popskill +import Foundation +import Testing + +struct SpotlightActionTests { + @Test + func spotlightActionsExposeUsageScan() { + #expect(SpotlightAction.all.map(\.id).contains("usage-scan")) + #expect(SpotlightAction.all.map(\.id).contains("show-bundles")) + #expect(SpotlightAction.all.map(\.id).contains("show-skills")) + #expect(SpotlightAction.all.map(\.id).contains("show-cli")) + #expect(SpotlightAction.all.map(\.id).contains("show-mcp")) + #expect(SpotlightAction.all.map(\.id).contains("show-broken-links")) + #expect(SpotlightAction.all.map(\.id).contains("show-inactive")) + + let usageScan = SpotlightAction.all.first { $0.id == "usage-scan" } + #expect(usageScan?.titleKey == "spotlight.action.usageScan.title") + #expect(usageScan?.subtitleKey == "spotlight.action.usageScan.subtitle") + } + + @Test + func spotlightRecentRankerPrefersUsageOverLifecycleForSkills() { + let lifecycleNewer = skillFixture(id: "newer-install", name: "Newer Install", installedAt: 1_900_000_000) + let recentlyUsed = skillFixture(id: "recently-used", name: "Recently Used", installedAt: 1_600_000_000) + let highCalls = skillFixture(id: "high-calls", name: "High Calls", installedAt: 1_500_000_000) + let summary = UsageSummary( + skillStats: [ + usageStat(skillID: "recently-used", calls: 1, tokens: 10, lastUsedAt: 1_800_000_000), + usageStat(skillID: "high-calls", calls: 20, tokens: 200, lastUsedAt: 1_700_000_000) + ] + ) + + let ranked = SpotlightRecentRanker.recentSkills( + [lifecycleNewer, highCalls, recentlyUsed], + summary: summary + ) + + #expect(ranked.map(\.id) == ["recently-used", "high-calls", "newer-install"]) + } + + @Test + func spotlightRecentRankerFallsBackToLifecycleWhenUsageIsMissing() { + let older = skillFixture(id: "older", name: "Older", installedAt: 100) + let newer = skillFixture(id: "newer", name: "Newer", updatedAt: 200) + + let ranked = SpotlightRecentRanker.recentSkills([older, newer], summary: nil) + + #expect(ranked.map(\.id) == ["newer", "older"]) + } + + @Test + func spotlightRecentRankerRanksPackagesByComponentUsage() { + let usedPackage = packageFixture( + id: "pkg-used", + name: "Used Package", + componentID: "used-skill", + lifecycleUpdatedAt: 100 + ) + let lifecycleNewer = packageFixture( + id: "pkg-newer", + name: "Lifecycle Newer", + componentID: "unused-skill", + lifecycleUpdatedAt: 1_900_000_000 + ) + let usedSkill = skillFixture(id: "used-skill", name: "Used Skill") + let summary = UsageSummary( + skillStats: [ + usageStat(skillID: "used-skill", calls: 2, tokens: 20, lastUsedAt: 1_800_000_000) + ] + ) + + let ranked = SpotlightRecentRanker.recentPackages( + [lifecycleNewer, usedPackage], + skills: [usedSkill], + summary: summary + ) + + #expect(ranked.map(\.id) == ["pkg-used", "pkg-newer"]) + } + + @MainActor + @Test + func spotlightMatrixFilterActionsOpenExpectedMatrixViews() { + let store = PopskillStore() + + store.searchText = "baoyu" + SpotlightAction.all.first { $0.id == "show-bundles" }?.run(store: store) + #expect(store.currentSelection == .matrix) + #expect(store.matrixTypeFilter == .bundle) + #expect(store.matrixFilter == .all) + #expect(store.searchText.isEmpty) + + SpotlightAction.all.first { $0.id == "show-skills" }?.run(store: store) + #expect(store.currentSelection == .matrix) + #expect(store.matrixTypeFilter == .skill) + #expect(store.matrixFilter == .all) + + SpotlightAction.all.first { $0.id == "show-cli" }?.run(store: store) + #expect(store.currentSelection == .matrix) + #expect(store.matrixTypeFilter == .cli) + #expect(store.matrixFilter == .all) + + SpotlightAction.all.first { $0.id == "show-mcp" }?.run(store: store) + #expect(store.currentSelection == .matrix) + #expect(store.matrixTypeFilter == .mcp) + #expect(store.matrixFilter == .all) + + SpotlightAction.all.first { $0.id == "show-broken-links" }?.run(store: store) + #expect(store.currentSelection == .matrix) + #expect(store.matrixFilter == .brokenLinks) + #expect(store.matrixTypeFilter == .allTypes) + + SpotlightAction.all.first { $0.id == "show-inactive" }?.run(store: store) + #expect(store.currentSelection == .matrix) + #expect(store.matrixFilter == .inactive) + #expect(store.matrixTypeFilter == .allTypes) + } + + private func skillFixture( + id: String, + name: String, + installedAt: Int? = nil, + updatedAt: Int? = nil + ) -> Skill { + Skill( + id: id, + name: name, + description: "\(name) description", + directory: id, + repoOwner: nil, + repoName: nil, + readmeUrl: nil, + apps: SkillApps(claude: true, codex: true, gemini: false, opencode: false, hermes: false), + installedAt: installedAt, + updatedAt: updatedAt, + contentHash: nil + ) + } + + private func packageFixture( + id: String, + name: String, + componentID: String, + lifecycleUpdatedAt: Int + ) -> CapabilityPackage { + CapabilityPackage( + id: id, + type: .composite, + name: name, + vendor: nil, + summary: "\(name) summary", + source: PackageSource( + kind: "builtin", + location: id, + updateStrategy: "manual", + repoOwner: nil, + repoName: nil, + repoBranch: nil, + readmeUrl: nil + ), + components: PackageComponents( + cli: [], + skills: [ + PackageComponent( + id: componentID, + name: componentID, + kind: "skill", + required: true, + installed: true, + status: "installed", + location: componentID + ) + ], + mcp: [], + agents: [] + ), + configSchema: [], + installed: true, + lifecycle: PackageLifecycle( + installedAt: nil, + updatedAt: lifecycleUpdatedAt, + contentHash: nil + ) + ) + } + + private func usageStat( + skillID: String, + calls: Int, + tokens: Int64, + lastUsedAt: TimeInterval + ) -> SkillUsageStat { + SkillUsageStat( + skillID: skillID, + sourcePlugin: nil, + usageEvents: calls, + inputTokens: tokens, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + lastUsedAt: Date(timeIntervalSince1970: lastUsedAt) + ) + } +} diff --git a/swift-app/Tests/PopskillTests/TranscriptUsageScannerTests.swift b/swift-app/Tests/PopskillTests/TranscriptUsageScannerTests.swift index 8fffcd2..ace2186 100644 --- a/swift-app/Tests/PopskillTests/TranscriptUsageScannerTests.swift +++ b/swift-app/Tests/PopskillTests/TranscriptUsageScannerTests.swift @@ -113,6 +113,45 @@ struct TranscriptUsageScannerTests { #expect(stat.lastUsedAt != nil) } + @Test + func recentThirtyDayWindowAggregatesOnlyRecentUsage() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let project = root.appendingPathComponent("project", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: root) + } + + let transcript = project.appendingPathComponent("session.jsonl") + let lines = [ + #"{"type":"assistant","sessionId":"recent","timestamp":"2026-05-12T01:01:00.000Z","attributionSkill":"recent-skill","message":{"role":"assistant","model":"claude-opus","usage":{"input_tokens":10,"output_tokens":5}}}"#, + #"{"type":"assistant","sessionId":"recent","timestamp":"2026-05-13T02:01:00.000Z","attributionSkill":"recent-skill","message":{"role":"assistant","model":"claude-opus","usage":{"input_tokens":1,"output_tokens":1}}}"#, + #"{"type":"assistant","sessionId":"old","timestamp":"2026-03-01T01:01:00.000Z","attributionSkill":"old-skill","message":{"role":"assistant","model":"claude-sonnet","usage":{"input_tokens":90,"output_tokens":10}}}"# + ] + try lines.joined(separator: "\n").write(to: transcript, atomically: true, encoding: .utf8) + + let referenceDate = try #require(Self.iso8601.date(from: "2026-05-20T00:00:00Z")) + let summary = try TranscriptUsageScanner(projectsURL: root, referenceDate: referenceDate).scan() + + #expect(summary.usageEvents == 3) + #expect(summary.totalTokens == 117) + #expect(summary.skillStats.map(\.skillID) == ["old-skill", "recent-skill"]) + #expect(summary.recent30Days?.days == 30) + #expect(summary.recent30Days?.usageEvents == 2) + #expect(summary.recent30Days?.totalTokens == 17) + #expect(summary.recent30Days?.skillStats.map(\.skillID) == ["recent-skill"]) + #expect(summary.recent30Days?.modelStats.map(\.model) == ["claude-opus"]) + + let dailyStats = try #require(summary.recent30Days?.dailyStats) + #expect(dailyStats.map(\.usageEvents) == [1, 1]) + #expect(dailyStats.map(\.totalTokens) == [15, 2]) + + let skillDailyStats = try #require(summary.recent30Days?.skillStats.first?.dailyStats) + #expect(skillDailyStats.map(\.usageEvents) == [1, 1]) + #expect(skillDailyStats.map(\.totalTokens) == [15, 2]) + } + @Test func streamsCRLFLinesAndSkipsMalformedRecords() throws { let root = FileManager.default.temporaryDirectory @@ -139,4 +178,10 @@ struct TranscriptUsageScannerTests { #expect(summary.inputTokens == 6) #expect(summary.outputTokens == 8) } + + private static let iso8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() }