From 77521d099a0aeabc0f30e5969b402fe82ddf0407 Mon Sep 17 00:00:00 2001 From: voidcat Date: Tue, 14 Jul 2026 16:12:36 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(project-sync):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=8A=80=E8=83=BD=E6=95=B4=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=B8=8E=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/SkillDeck/Models/AgentType.swift | 49 ++- .../Services/AgentPathSettings.swift | 32 ++ .../SkillDeck/Services/CommitHashCache.swift | 9 +- .../SkillDeck/Services/LockFileManager.swift | 9 +- .../Services/ProjectSkillUpdateService.swift | 133 ++++++ Sources/SkillDeck/Services/SkillManager.swift | 135 ++---- Sources/SkillDeck/Services/SkillScanner.swift | 58 +-- .../SkillDeck/Services/SymlinkManager.swift | 84 +--- Sources/SkillDeck/Utilities/Constants.swift | 1 + .../ViewModels/ClawHubBrowserViewModel.swift | 3 +- .../ViewModels/LocalImportViewModel.swift | 23 +- .../SkillDeck/ViewModels/ProjectManager.swift | 404 ++++++++++++++++++ .../ProjectSkillDetailViewModel.swift | 89 ++++ .../ViewModels/SkillDetailViewModel.swift | 12 - .../ViewModels/SkillInstallViewModel.swift | 18 +- .../Views/Components/AgentToggleView.swift | 124 ------ Sources/SkillDeck/Views/ContentView.swift | 39 +- .../Views/Detail/SkillDetailView.swift | 15 - .../Views/Install/LocalImportView.swift | 27 +- .../Views/Install/SkillInstallView.swift | 35 +- .../Projects/GlobalSkillSourceList.swift | 57 +++ .../Projects/ProjectManagementView.swift | 341 +++++++++++++++ .../Projects/ProjectSkillDetailView.swift | 158 +++++++ Sources/SkillDeck/Views/SettingsView.swift | 48 ++- .../SkillDeck/Views/Sidebar/SidebarView.swift | 28 +- Tests/SkillDeckTests/AgentTypeTests.swift | 4 +- .../GlobalSkillStorageTests.swift | 34 ++ .../ProjectSkillUpdateServiceTests.swift | 52 +++ .../ProjectSyncFileSystemTests.swift | 122 ++++++ .../SkillManagerToggleTests.swift | 293 ------------- docs/PROJECT_MANAGEMENT.md | 107 +++++ 31 files changed, 1741 insertions(+), 802 deletions(-) create mode 100644 Sources/SkillDeck/Services/ProjectSkillUpdateService.swift create mode 100644 Sources/SkillDeck/ViewModels/ProjectManager.swift create mode 100644 Sources/SkillDeck/ViewModels/ProjectSkillDetailViewModel.swift delete mode 100644 Sources/SkillDeck/Views/Components/AgentToggleView.swift create mode 100644 Sources/SkillDeck/Views/Projects/GlobalSkillSourceList.swift create mode 100644 Sources/SkillDeck/Views/Projects/ProjectManagementView.swift create mode 100644 Sources/SkillDeck/Views/Projects/ProjectSkillDetailView.swift create mode 100644 Tests/SkillDeckTests/GlobalSkillStorageTests.swift create mode 100644 Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift create mode 100644 Tests/SkillDeckTests/ProjectSyncFileSystemTests.swift delete mode 100644 Tests/SkillDeckTests/SkillManagerToggleTests.swift create mode 100644 docs/PROJECT_MANAGEMENT.md diff --git a/Sources/SkillDeck/Models/AgentType.swift b/Sources/SkillDeck/Models/AgentType.swift index 6c9b1be..cababf1 100644 --- a/Sources/SkillDeck/Models/AgentType.swift +++ b/Sources/SkillDeck/Models/AgentType.swift @@ -17,6 +17,9 @@ enum AgentType: String, CaseIterable, Identifiable, Codable { case qoder = "qoder" // Qoder: AI coding agent (https://qoder.ai) case qclaw = "qclaw" // QClaw: AI coding assistant (skills in ~/.qclaw/skills) case workbuddy = "workbuddy" // WorkBuddy: AI coding assistant (skills in ~/.workbuddy/skills) + case joyCode = "joycode" + case reasonix = "reasonix" + case qwen = "qwen" // Identifiable protocol requirement (similar to Java's Comparable), needed for SwiftUI list rendering var id: String { rawValue } @@ -37,6 +40,9 @@ enum AgentType: String, CaseIterable, Identifiable, Codable { case .qoder: "Qoder" case .qclaw: "QClaw" case .workbuddy: "WorkBuddy" + case .joyCode: "JoyCode" + case .reasonix: "Reasonix" + case .qwen: "Qwen" } } @@ -58,6 +64,7 @@ enum AgentType: String, CaseIterable, Identifiable, Codable { case .qoder: "orange" case .qclaw: "mint" case .workbuddy: "yellow" + case .joyCode, .reasonix, .qwen: "gray" } } @@ -79,6 +86,7 @@ enum AgentType: String, CaseIterable, Identifiable, Codable { case .qoder: "q.circle" // Letter Q icon for Qoder case .qclaw: "hand.raised.circle" // Hand/claw icon for QClaw case .workbuddy: "w.circle" // Letter W icon for WorkBuddy + case .joyCode, .reasonix, .qwen: "terminal" // Default icon until a dedicated symbol is available } } @@ -119,6 +127,12 @@ enum AgentType: String, CaseIterable, Identifiable, Codable { return "~/.qclaw/skills" // QClaw AI assistant skills directory case .workbuddy: return "~/.workbuddy/skills" // WorkBuddy AI assistant skills directory + case .joyCode: + return "~/.joycode/skills" + case .reasonix: + return "~/.reasonix/skills" + case .qwen: + return "~/.qwen/skills" } } @@ -145,6 +159,9 @@ enum AgentType: String, CaseIterable, Identifiable, Codable { case .qoder: "~/.qoder" case .qclaw: "~/.qclaw" case .workbuddy: "~/.workbuddy" + case .joyCode: "~/.joycode" + case .reasonix: "~/.reasonix" + case .qwen: "~/.qwen" } } @@ -165,16 +182,40 @@ enum AgentType: String, CaseIterable, Identifiable, Codable { case .qoder: "qoder" case .qclaw: "qclaw" case .workbuddy: "workbuddy" + case .joyCode: "joycode" + case .reasonix: "reasonix" + case .qwen: "qwen" } } /// Shared canonical skills directory URL (~/.agents/skills/) /// Used by SkillScanner and agents that read from the shared directory (e.g., OpenCode). /// Defined here as a single source of truth to avoid duplicating the path string. - static let sharedSkillsDirectoryURL: URL = { - let path = NSString(string: "~/.agents/skills").expandingTildeInPath - return URL(fileURLWithPath: path) - }() + static var sharedSkillsDirectoryURL: URL { SkillStorageSettings.globalSkillsURL } + + /// Project-local paths mirror each tool's user-level layout without inheriting user-only overrides. + /// For example, a Docker-specific OpenClaw path must not be written into an unrelated project. + var projectSkillsRelativePath: String { + switch self { + case .claudeCode: ".claude/skills" + case .codex: ".codex/skills" + case .geminiCLI: ".gemini/skills" + case .copilotCLI: ".copilot/skills" + case .openCode: ".config/opencode/skills" + case .antigravity: ".gemini/antigravity/skills" + case .cursor: ".cursor/skills" + case .kiro: ".kiro/skills" + case .codeBuddy: ".codebuddy/skills" + case .openClaw: ".openclaw/skills" + case .trae: ".trae/skills" + case .qoder: ".qoder/skills" + case .qclaw: ".qclaw/skills" + case .workbuddy: ".workbuddy/skills" + case .joyCode: ".joycode/skills" + case .reasonix: ".reasonix/skills" + case .qwen: ".qwen/skills" + } + } /// Skills directories of other Agents that this Agent can read in addition to its own skills directory /// diff --git a/Sources/SkillDeck/Services/AgentPathSettings.swift b/Sources/SkillDeck/Services/AgentPathSettings.swift index 6291ff5..98bdd89 100644 --- a/Sources/SkillDeck/Services/AgentPathSettings.swift +++ b/Sources/SkillDeck/Services/AgentPathSettings.swift @@ -1,5 +1,37 @@ import Foundation +/// SkillStorageSettings stores the canonical global skill directory selected by the user. +/// UserDefaults is suitable here because this is a small application preference, comparable to +/// a Java Preferences value rather than application data that needs a separate database. +enum SkillStorageSettings { + private static let globalSkillsPathKey = "globalSkillsPath" + + static var globalSkillsPath: String { + let configuredPath = UserDefaults.standard.string(forKey: globalSkillsPathKey) + return configuredPath?.isEmpty == false ? configuredPath! : Constants.sharedSkillsPath + } + + static var globalSkillsURL: URL { + URL(fileURLWithPath: NSString(string: globalSkillsPath).expandingTildeInPath) + } + + static var lockFileURL: URL { + globalSkillsURL.deletingLastPathComponent().appendingPathComponent(".skill-lock.json") + } + + static var cacheFileURL: URL { + globalSkillsURL.deletingLastPathComponent().appendingPathComponent(".skilldeck-cache.json") + } + + static func setGlobalSkillsPath(_ path: String?) { + if let path, !path.isEmpty { + UserDefaults.standard.set(path, forKey: globalSkillsPathKey) + } else { + UserDefaults.standard.removeObject(forKey: globalSkillsPathKey) + } + } +} + /// AgentPathSettings manages custom directory paths for Agents /// /// This allows users to configure non-default paths for Agent skills directories, diff --git a/Sources/SkillDeck/Services/CommitHashCache.swift b/Sources/SkillDeck/Services/CommitHashCache.swift index bc7e546..89e9531 100644 --- a/Sources/SkillDeck/Services/CommitHashCache.swift +++ b/Sources/SkillDeck/Services/CommitHashCache.swift @@ -74,10 +74,7 @@ actor CommitHashCache { /// Default path for the cache file: ~/.agents/.skilldeck-cache.json /// `static let` is a compile-time constant, similar to Java's static final - static let defaultPath: URL = { - let home = NSString(string: "~/.agents/.skilldeck-cache.json").expandingTildeInPath - return URL(fileURLWithPath: home) - }() + static var defaultPath: URL { SkillStorageSettings.cacheFileURL } /// Current cache file path (can be overridden in tests) private let filePath: URL @@ -99,8 +96,8 @@ actor CommitHashCache { // MARK: - Initialization - init(filePath: URL = CommitHashCache.defaultPath) { - self.filePath = filePath + init(filePath: URL? = nil) { + self.filePath = filePath ?? Self.defaultPath } // MARK: - Public Methods diff --git a/Sources/SkillDeck/Services/LockFileManager.swift b/Sources/SkillDeck/Services/LockFileManager.swift index 06c4d55..03b3c8e 100644 --- a/Sources/SkillDeck/Services/LockFileManager.swift +++ b/Sources/SkillDeck/Services/LockFileManager.swift @@ -9,10 +9,7 @@ import Foundation actor LockFileManager { /// Default path for lock file - static let defaultPath: URL = { - let home = NSString(string: "~/.agents/.skill-lock.json").expandingTildeInPath - return URL(fileURLWithPath: home) - }() + static var defaultPath: URL { SkillStorageSettings.lockFileURL } /// Currently used lock file path (can be overridden in tests) let filePath: URL @@ -20,8 +17,8 @@ actor LockFileManager { /// In-memory cached lock file data private var cached: LockFile? - init(filePath: URL = LockFileManager.defaultPath) { - self.filePath = filePath + init(filePath: URL? = nil) { + self.filePath = filePath ?? Self.defaultPath } /// Read and parse lock file diff --git a/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift b/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift new file mode 100644 index 0000000..f82a802 --- /dev/null +++ b/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift @@ -0,0 +1,133 @@ +import Foundation + +/// ProjectSkillUpdateService owns repository metadata for one project's `.agents/skills` directory. +/// It deliberately uses a project-local lock file so update operations cannot overwrite a global skill +/// with the same folder name. +actor ProjectSkillUpdateService { + struct UpdateCheck: Equatable { + let hasUpdate: Bool + let remoteHash: String + } + + private let sourceRoot: URL + private let lockFileManager: LockFileManager + private let gitService = GitService() + + init(sourceRoot: URL) { + self.sourceRoot = sourceRoot + let agentsRoot = sourceRoot.deletingLastPathComponent() + self.lockFileManager = LockFileManager(filePath: agentsRoot.appendingPathComponent(".skill-lock.json")) + } + + /// Loads a project skill together with its project-local repository metadata. + func loadSkill(named name: String, at directory: URL) async throws -> Skill { + let parsed = try SkillMDParser.parse(fileURL: directory.appendingPathComponent("SKILL.md")) + let entry = await lockEntry(for: name) + return Skill( + id: name, + canonicalURL: directory, + metadata: parsed.metadata, + markdownBody: parsed.markdownBody, + scope: .project(sourceRoot.deletingLastPathComponent().deletingLastPathComponent()), + installations: [], + lockEntry: entry + ) + } + + /// Associates a project skill with a GitHub repository and records a project-local baseline. + func link(skill: Skill, repositoryInput: String) async throws -> Skill { + let (repositoryURL, source) = try GitService.normalizeRepoURL(repositoryInput) + let repository = try await gitService.shallowClone(repoURL: repositoryURL) + defer { Task { await gitService.cleanupTempDirectory(repository) } } + + let discovered = await gitService.scanSkillsInRepo(repoDir: repository, repoURL: repositoryURL) + guard let remoteSkill = discovered.first(where: { $0.id == skill.id }) else { + throw ProjectSkillUpdateError.skillNotFoundInRepository(skill.id) + } + + let treeHash = try await gitService.getTreeHash(for: remoteSkill.folderPath, in: repository) + try replaceLocalSkill(at: skill.canonicalURL, from: sourceDirectory(for: remoteSkill.folderPath, in: repository)) + + let now = ISO8601DateFormatter().string(from: Date()) + let entry = LockEntry( + source: source, + sourceType: "github", + sourceUrl: repositoryURL, + skillPath: remoteSkill.skillMDPath, + skillFolderHash: treeHash, + installedAt: now, + updatedAt: now + ) + try await lockFileManager.createIfNotExists() + try await lockFileManager.updateEntry(skillName: skill.id, entry: entry) + return try await loadSkill(named: skill.id, at: skill.canonicalURL) + } + + /// Checks the project's stored Git baseline against the latest remote folder tree. + func checkForUpdate(skill: Skill) async throws -> UpdateCheck { + guard let entry = skill.lockEntry, entry.sourceType == "github" else { + throw ProjectSkillUpdateError.repositoryNotLinked + } + let repository = try await gitService.shallowClone(repoURL: entry.sourceUrl) + defer { Task { await gitService.cleanupTempDirectory(repository) } } + + let remoteHash = try await gitService.getTreeHash(for: folderPath(from: entry.skillPath), in: repository) + return UpdateCheck(hasUpdate: remoteHash != entry.skillFolderHash, remoteHash: remoteHash) + } + + /// Replaces only the selected project skill directory and advances its project-local lock entry. + func update(skill: Skill, remoteHash: String) async throws -> Skill { + guard var entry = skill.lockEntry, entry.sourceType == "github" else { + throw ProjectSkillUpdateError.repositoryNotLinked + } + let repository = try await gitService.shallowClone(repoURL: entry.sourceUrl) + defer { Task { await gitService.cleanupTempDirectory(repository) } } + + try replaceLocalSkill(at: skill.canonicalURL, from: sourceDirectory(for: folderPath(from: entry.skillPath), in: repository)) + entry.skillFolderHash = remoteHash + entry.updatedAt = ISO8601DateFormatter().string(from: Date()) + try await lockFileManager.updateEntry(skillName: skill.id, entry: entry) + return try await loadSkill(named: skill.id, at: skill.canonicalURL) + } + + private func lockEntry(for skillName: String) async -> LockEntry? { + await lockFileManager.invalidateCache() + guard await lockFileManager.exists else { return nil } + return try? await lockFileManager.getEntry(skillName: skillName) + } + + private func sourceDirectory(for folderPath: String, in repository: URL) -> URL { + folderPath.isEmpty ? repository : repository.appendingPathComponent(folderPath) + } + + private func folderPath(from skillPath: String) -> String { + if skillPath == "SKILL.md" { return "" } + if skillPath.hasSuffix("/SKILL.md") { + return String(skillPath.dropLast("/SKILL.md".count)) + } + return skillPath + } + + private func replaceLocalSkill(at destination: URL, from source: URL) throws { + let fileManager = FileManager.default + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.copyItem(at: source, to: destination) + } +} + +enum ProjectSkillUpdateError: LocalizedError { + case repositoryNotLinked + case skillNotFoundInRepository(String) + + var errorDescription: String? { + switch self { + case .repositoryNotLinked: + return "请先关联 GitHub 仓库" + case .skillNotFoundInRepository(let name): + return "仓库中未找到技能:\(name)" + } + } +} diff --git a/Sources/SkillDeck/Services/SkillManager.swift b/Sources/SkillDeck/Services/SkillManager.swift index da74484..b2210e2 100644 --- a/Sources/SkillDeck/Services/SkillManager.swift +++ b/Sources/SkillDeck/Services/SkillManager.swift @@ -125,7 +125,7 @@ final class SkillManager { private let scanner = SkillScanner() private let detector = AgentDetector() - private let lockFileManager = LockFileManager() + private var lockFileManager = LockFileManager() private let watcher = FileSystemWatcher() /// Application self-update checker (GitHub Release check, download, install) private let updateChecker = UpdateChecker() @@ -133,7 +133,7 @@ final class SkillManager { private let gitService = GitService() /// F12: SkillDeck private commit hash cache, independent of .skill-lock.json /// Stored in ~/.agents/.skilldeck-cache.json, doesn't pollute npx skills' lock file format - private let commitHashCache = CommitHashCache() + private var commitHashCache = CommitHashCache() private let translationService = TranslationService() @@ -156,6 +156,15 @@ final class SkillManager { setupFileWatcher() } + /// Recreate services that persist beside the configured canonical skills directory. + /// Actor instances retain their file URL at initialization, so changing the setting must replace + /// them before refresh rather than continuing to use the previous directory's metadata files. + func reloadConfiguredStorage() async { + lockFileManager = LockFileManager() + commitHashCache = CommitHashCache() + await refresh() + } + /// Translate a short English paragraph into Simplified Chinese (zh-CN). /// /// This is intentionally exposed on SkillManager so views can access it via @@ -306,38 +315,21 @@ final class SkillManager { // MARK: - F04: Skill Deletion - /// Delete a skill - /// - /// Deletion flow: - /// 1. Remove direct installation symlinks from all Agents (skip inherited installations) - /// 2. Delete canonical directory (actual files) - /// 3. Update lock file - /// 4. Refresh data - /// - /// Inherited installation symlinks don't need separate deletion: they point to symlinks in the source Agent directory, - /// and the source Agent's symlink will be deleted in step 1; even if not deleted, after the canonical directory is removed - /// they become dangling symlinks, which don't affect functionality + /// Delete a skill from the configured canonical directory and remove its lock entry. + /// Whole-directory links remain valid because they point at the containing directory, not this skill. func deleteSkill(_ skill: Skill) async throws { - // 1. Remove all direct installation symlinks (skip inherited installations) - for installation in skill.installations where installation.isSymlink && !installation.isInherited { - try SymlinkManager.removeSymlink( - skillName: skill.id, - from: installation.agentType - ) - } - - // 2. Delete canonical directory + // 1. Delete canonical directory let fm = FileManager.default if fm.fileExists(atPath: skill.canonicalURL.path) { try fm.removeItem(at: skill.canonicalURL) } - // 3. Update lock file (if there's a record) + // 2. Update lock file (if there's a record) if skill.lockEntry != nil { try await lockFileManager.removeEntry(skillName: skill.id) } - // 4. Refresh list + // 3. Refresh list await refresh() } @@ -351,79 +343,26 @@ final class SkillManager { await refresh() } - // MARK: - F06: Agent Assignment (Toggle Symlink) - - /// Install skill to specified Agent (create symlink) - func assignSkill(_ skill: Skill, to agent: AgentType) async throws { - try SymlinkManager.createSymlink(from: skill.canonicalURL, to: agent) - await refresh() - } - - /// Uninstall skill from specified Agent (delete symlink) - func unassignSkill(_ skill: Skill, from agent: AgentType) async throws { - print("[SkillManager] unassignSkill called for \(skill.id) from \(agent.displayName)") - try SymlinkManager.removeSymlink(skillName: skill.id, from: agent) - print("[SkillManager] unassignSkill: symlink removed, refreshing...") - await refresh() - print("[SkillManager] unassignSkill: refresh completed") - } - - /// Toggle skill installation status on specified Agent - /// - /// Protection logic: if inherited installation (isInherited), return directly without any operation - /// This is Service layer defense - even if UI layer disables Toggle, it ensures inherited installations won't be mistakenly operated - func toggleAssignment(_ skill: Skill, agent: AgentType) async throws { - // Get latest skill data from self.skills to ensure we have current installations - let latestSkill = self.skills.first { $0.id == skill.id } ?? skill - - let installation = latestSkill.installations.first { $0.agentType == agent } - - // Protection: inherited installations cannot be toggled (inherited installations are managed by source Agent) - // For Codex: reading from ~/.agents/skills/ is not truly "inherited" - it's native support - let isTrulyInherited: Bool = { - guard let installation = installation else { return false } - if agent == .codex && installation.inheritedFrom == .codex { - return false - } - return installation.isInherited - }() - if isTrulyInherited { - print("[SkillManager] toggleAssignment blocked: truly inherited installation") - return - } - - let isInstalled = installation != nil - print("[SkillManager] toggleAssignment: agent=\(agent.displayName), isInstalled=\(isInstalled)") - if isInstalled { - try await unassignSkill(latestSkill, from: agent) - } else { - try await assignSkill(latestSkill, to: agent) - } - } - // MARK: - F10: One-Click Install /// Install skill from cloned repository to local /// /// Installation flow: /// 1. Get tree hash (for lock file recording, subsequent update detection) - /// 2. Copy files to canonical directory (~/.agents/skills//) - /// 3. Create symlinks for selected Agents (skip Codex as it shares canonical directory) - /// 4. Create/update lock file entry - /// 5. Refresh UI + /// 2. Copy files to the configured canonical directory (//) + /// 3. Create/update lock file entry + /// 4. Refresh UI /// /// - Parameters: /// - repoDir: Local temporary directory of cloned repository /// - skill: Skill info to install (from GitService.scanSkillsInRepo) /// - repoSource: Repository source identifier (e.g. "vercel-labs/skills", for lock file) /// - repoURL: Full repository URL (e.g. "https://github.com/vercel-labs/skills.git") - /// - targetAgents: Set of Agents to install to func installSkill( from repoDir: URL, skill: GitService.DiscoveredSkill, repoSource: String, - repoURL: String, - targetAgents: Set + repoURL: String ) async throws { // 1. Get tree hash (git rev-parse HEAD:) let treeHash = try await gitService.getTreeHash(for: skill.folderPath, in: repoDir) @@ -458,7 +397,6 @@ final class SkillManager { try await persistInstalledSkillDirectory( from: sourceDir, skillName: skill.id, - targetAgents: targetAgents, lockEntry: entry ) } @@ -469,10 +407,9 @@ final class SkillManager { /// /// Flow: /// 1. Validate the source directory exists and contains a parseable SKILL.md - /// 2. Copy the entire directory to canonical path (~/.agents/skills//) - /// 3. Create symlinks for selected Agents - /// 4. Update lock file with sourceType "local" and source = original directory path - /// 5. Refresh UI + /// 2. Copy the entire directory to the configured canonical path (//) + /// 3. Update lock file with sourceType "local" and source = original directory path + /// 4. Refresh UI /// /// Unlike installSkill (GitHub-based), local imports have no git hash or remote URL. /// The lock entry uses sourceType "local" so that checkAllUpdates() can skip them @@ -481,11 +418,9 @@ final class SkillManager { /// - Parameters: /// - sourceURL: Local directory URL containing SKILL.md /// - skillName: Name for the skill (typically the directory name) - /// - targetAgents: Set of Agents to create symlinks for func importLocalSkill( from sourceURL: URL, - skillName: String, - targetAgents: Set + skillName: String ) async throws { let fm = FileManager.default @@ -523,23 +458,21 @@ final class SkillManager { try await persistInstalledSkillDirectory( from: sourceURL, skillName: skillName, - targetAgents: targetAgents, lockEntry: entry ) } - /// Install a skill fetched from ClawHub into the canonical directory, then expose it to OpenClaw. + /// Install a skill fetched from ClawHub into the canonical directory. /// /// We intentionally do not shell out to `clawhub install`. SkillDeck keeps its own canonical - /// storage model (`~/.agents/skills`) and only creates the OpenClaw symlink after the files are - /// safely persisted locally. This keeps installation behavior consistent with the rest of the app. + /// storage model (the configured global skills directory). Whole-directory synchronization exposes it to OpenClaw + /// and every other configured tool after the files are safely persisted locally. func installClawHubSkill( slug: String, version: String, detailPageURL: String, skillContent: String?, - archiveData: Data?, - targetAgents: Set + archiveData: Data? ) async throws -> ClawHubInstallResult { let fm = FileManager.default let tempRoot = fm.temporaryDirectory.appendingPathComponent("SkillDeck-ClawHub-\(UUID().uuidString)") @@ -569,7 +502,6 @@ final class SkillManager { try await persistInstalledSkillDirectory( from: extractedSkillDir, skillName: slug, - targetAgents: targetAgents, lockEntry: entry ) return .installedFromArchive @@ -599,7 +531,6 @@ final class SkillManager { try await persistInstalledSkillDirectory( from: markdownOnlyDir, skillName: slug, - targetAgents: targetAgents, lockEntry: entry ) return .installedSkillMarkdownOnly @@ -951,13 +882,11 @@ final class SkillManager { /// Shared persistence helper used by GitHub installs, local imports, and ClawHub installs. /// - /// The source directory is copied into SkillDeck's canonical storage first, then symlinks are - /// created for each selected Agent. Keeping that order matters because Agent directories should - /// always point at a fully materialized canonical directory rather than a temporary location. + /// The source directory is copied into SkillDeck's canonical storage. Agent directories are + /// synchronized separately as whole-directory links, never as per-skill links. private func persistInstalledSkillDirectory( from sourceURL: URL, skillName: String, - targetAgents: Set, lockEntry: LockEntry ) async throws { let fm = FileManager.default @@ -1032,10 +961,6 @@ final class SkillManager { throw ImportError.directoryNotFound("Failed to copy skill files after \(maxCopyAttempts) attempts: \(error.localizedDescription)") } - for agent in targetAgents { - try? SymlinkManager.createSymlink(from: canonicalDir, to: agent) - } - try await lockFileManager.createIfNotExists() try await lockFileManager.updateEntry(skillName: skillName, entry: lockEntry) await refresh() diff --git a/Sources/SkillDeck/Services/SkillScanner.swift b/Sources/SkillDeck/Services/SkillScanner.swift index 8f36f0c..e583680 100644 --- a/Sources/SkillDeck/Services/SkillScanner.swift +++ b/Sources/SkillDeck/Services/SkillScanner.swift @@ -3,62 +3,20 @@ import Foundation /// SkillScanner is responsible for scanning the file system to discover all installed skills /// /// Scanning strategy: -/// 1. First scan ~/.agents/skills/ (shared global directory) -/// 2. Then scan each Agent's skills directory -/// 3. Deduplicate via symlink resolution: if a skill in an Agent directory is a symlink to ~/.agents/skills/, -/// keep only one copy and record it in installations +/// 1. Scan the configured global skills directory as the only canonical source. +/// 2. Inspect the whole-directory links only to report each skill's synchronized tools. /// /// This is similar to filepath.Walk in Go for traversing directory trees actor SkillScanner { - /// Shared global skills directory (delegates to AgentType.sharedSkillsDirectoryURL for single source of truth) - static let sharedSkillsURL: URL = AgentType.sharedSkillsDirectoryURL + /// Shared global skills directory, resolved from the user's storage setting each time it is used. + static var sharedSkillsURL: URL { SkillStorageSettings.globalSkillsURL } - /// Scan all skills, returning deduplicated results - /// - Returns: Array of discovered skills (deduplicated, each skill name appears only once) + /// Scan the canonical global skills directory. + /// - Returns: Skills owned by the configured global directory; agent directories never add independent entries. func scanAll() async throws -> [Skill] { - // Use skill id (directory name) as deduplication key, not canonicalURL.path - // Reason: the same skill might be pointed to by different Agent symlinks to different physical paths - // e.g. ~/.copilot/skills/agent-notifier -> /path/to/dev/agent-notifier - // ~/.agents/skills/agent-notifier (another physical path) - // Although canonicalURL is different, skill id is the same, should be treated as same skill - var skillMap: [String: Skill] = [:] - - // 1. Scan shared global directory - let globalSkills = scanDirectory(Self.sharedSkillsURL, scope: .sharedGlobal) - for skill in globalSkills { - skillMap[skill.id] = skill - } - - // 2. Scan each Agent's skills directory - for agentType in AgentType.allCases { - - let agentSkills = scanDirectory( - agentType.skillsDirectoryURL, - scope: .agentLocal(agentType) - ) - - for skill in agentSkills { - if var existingSkill = skillMap[skill.id] { - // Same name skill exists: merge installations (indicates same skill referenced by multiple Agents) - let newInstallations = skill.installations.filter { newInst in - !existingSkill.installations.contains(where: { $0.id == newInst.id }) - } - existingSkill.installations.append(contentsOf: newInstallations) - // If previously agentLocal, now found referenced by other Agents, upgrade to sharedGlobal - if case .agentLocal = existingSkill.scope, existingSkill.installations.count > 1 { - existingSkill.scope = .sharedGlobal - } - skillMap[skill.id] = existingSkill - } else { - // New skill: add directly - skillMap[skill.id] = skill - } - } - } - - // Return sorted by name - return skillMap.values.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() } + scanDirectory(Self.sharedSkillsURL, scope: .sharedGlobal) + .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() } } /// Scan all skills in a single directory diff --git a/Sources/SkillDeck/Services/SymlinkManager.swift b/Sources/SkillDeck/Services/SymlinkManager.swift index 15a7258..ad7025d 100644 --- a/Sources/SkillDeck/Services/SymlinkManager.swift +++ b/Sources/SkillDeck/Services/SymlinkManager.swift @@ -1,92 +1,14 @@ import Foundation -/// SymlinkManager is responsible for creating and removing symlinks (F06 Agent Assignment) +/// SymlinkManager provides filesystem inspection for whole-directory skill synchronization. /// /// Core Concepts: -/// - The "real copy" of all skills is stored in ~/.agents/skills/ (canonical location) -/// - Each Agent references the shared skill via symlink -/// - Example: ~/.claude/skills/agent-notifier -> ~/.agents/skills/agent-notifier +/// - The canonical directory is selected in SkillDeck settings. +/// - Each Agent can reference it through a single directory symlink. /// /// symlink is similar to Linux/macOS `ln -s`, a special file pointing to another file/directory enum SymlinkManager { - enum SymlinkError: Error, LocalizedError { - case sourceNotFound(URL) - case targetAlreadyExists(URL) - case targetDirectoryNotFound(URL) - case removalFailed(URL, Error) - - var errorDescription: String? { - switch self { - case .sourceNotFound(let url): - "Skill source directory not found: \(url.path)" - case .targetAlreadyExists(let url): - "Target already exists: \(url.path)" - case .targetDirectoryNotFound(let url): - "Agent skills directory not found: \(url.path)" - case .removalFailed(let url, let error): - "Failed to remove symlink at \(url.path): \(error.localizedDescription)" - } - } - } - - /// Create symlink for skill to specified Agent's skills directory - /// - /// - Parameters: - /// - source: canonical path of the skill (e.g. ~/.agents/skills/agent-notifier/) - /// - agent: target Agent type - /// - Throws: SymlinkError - /// - /// Effect: agent.skillsDirectoryURL/skillName -> source - static func createSymlink(from source: URL, to agent: AgentType) throws { - let fm = FileManager.default - let skillName = source.lastPathComponent - let targetDir = agent.skillsDirectoryURL - let targetURL = targetDir.appendingPathComponent(skillName) - - // 1. Verify source directory exists - guard fm.fileExists(atPath: source.path) else { - throw SymlinkError.sourceNotFound(source) - } - - // 2. Ensure target Agent's skills directory exists, create if not - if !fm.fileExists(atPath: targetDir.path) { - // withIntermediateDirectories: true is similar to mkdir -p, creates parent directories recursively - try fm.createDirectory(at: targetDir, withIntermediateDirectories: true) - } - - // 3. Check if target location already exists - guard !fm.fileExists(atPath: targetURL.path) else { - throw SymlinkError.targetAlreadyExists(targetURL) - } - - // 4. Create symlink - // createSymbolicLink is equivalent to ln -s source targetURL - try fm.createSymbolicLink(at: targetURL, withDestinationURL: source) - } - - /// Remove symlink of a skill under specified Agent - /// - /// - Parameters: - /// - skillName: skill directory name - /// - agent: Agent type - /// - Throws: SymlinkError - static func removeSymlink(skillName: String, from agent: AgentType) throws { - let fm = FileManager.default - let targetURL = agent.skillsDirectoryURL.appendingPathComponent(skillName) - - // Verify path is indeed a symlink to avoid deleting real directory by mistake - guard isSymlink(at: targetURL) else { - return // Not a symlink, return silently - } - - do { - try fm.removeItem(at: targetURL) - } catch { - throw SymlinkError.removalFailed(targetURL, error) - } - } - /// Check if given path is a symlink /// /// FileManager.fileExists automatically resolves symlinks (follows links), diff --git a/Sources/SkillDeck/Utilities/Constants.swift b/Sources/SkillDeck/Utilities/Constants.swift index c7842b2..cbb9810 100644 --- a/Sources/SkillDeck/Utilities/Constants.swift +++ b/Sources/SkillDeck/Utilities/Constants.swift @@ -24,6 +24,7 @@ enum Constants { case .qoder: Color(red: 0.96, green: 0.52, blue: 0.15) // Orange #F58426 case .qclaw: Color(red: 0.0, green: 0.78, blue: 0.55) // Mint #00C78B case .workbuddy: Color(red: 1.0, green: 0.80, blue: 0.0) // Yellow #FFCC00 + case .joyCode, .reasonix, .qwen: .gray } } } diff --git a/Sources/SkillDeck/ViewModels/ClawHubBrowserViewModel.swift b/Sources/SkillDeck/ViewModels/ClawHubBrowserViewModel.swift index 86b79c2..9bb82c6 100644 --- a/Sources/SkillDeck/ViewModels/ClawHubBrowserViewModel.swift +++ b/Sources/SkillDeck/ViewModels/ClawHubBrowserViewModel.swift @@ -279,8 +279,7 @@ final class ClawHubBrowserViewModel { version: version, detailPageURL: skill.browserURL.absoluteString, skillContent: skillContent, - archiveData: archiveData, - targetAgents: [.openClaw] + archiveData: archiveData ) syncInstalledSkills() diff --git a/Sources/SkillDeck/ViewModels/LocalImportViewModel.swift b/Sources/SkillDeck/ViewModels/LocalImportViewModel.swift index 4588ae7..2aeecd3 100644 --- a/Sources/SkillDeck/ViewModels/LocalImportViewModel.swift +++ b/Sources/SkillDeck/ViewModels/LocalImportViewModel.swift @@ -6,8 +6,8 @@ import AppKit /// Import flow consists of phases (finite state machine): /// 1. selectPath — User picks a local directory via NSOpenPanel /// 2. validating — Verify directory contains a valid SKILL.md -/// 3. selectAgents — User selects target Agents to install the skill to -/// 4. importing — Copy files and create symlinks +/// 3. selectAgents — Directory validated and ready to import into the canonical global directory +/// 4. importing — Copy files into the canonical directory /// 5. completed — Import finished successfully /// 6. error — Something went wrong, show error message /// @@ -76,9 +76,6 @@ final class LocalImportViewModel: Identifiable { /// When true, the UI shows a warning that the existing skill will be overwritten var alreadyExists: Bool = false - /// Set of target Agents selected by user (Claude Code selected by default) - var selectedAgents: Set = [.claudeCode] - /// Progress message displayed during validating/importing phases var progressMessage = "" @@ -192,7 +189,7 @@ final class LocalImportViewModel: Identifiable { phase = .selectAgents } - /// Execute the import: copy files, create symlinks, update lock file + /// Execute the import: copy files into the canonical directory and update the lock file. /// /// Delegates to SkillManager.importLocalSkill() which handles all the heavy lifting. /// Transitions to .completed on success, .error on failure. @@ -208,8 +205,7 @@ final class LocalImportViewModel: Identifiable { do { try await skillManager.importLocalSkill( from: dirURL, - skillName: skillName, - targetAgents: selectedAgents + skillName: skillName ) phase = .completed } catch { @@ -217,16 +213,6 @@ final class LocalImportViewModel: Identifiable { } } - /// Toggle selection state of an Agent - /// If the Agent is already selected, remove it; otherwise, add it - func toggleAgentSelection(_ agent: AgentType) { - if selectedAgents.contains(agent) { - selectedAgents.remove(agent) - } else { - selectedAgents.insert(agent) - } - } - /// Reset to initial state (start over for importing another skill) func reset() { phase = .selectPath @@ -234,7 +220,6 @@ final class LocalImportViewModel: Identifiable { skillMetadata = nil skillName = "" alreadyExists = false - selectedAgents = [.claudeCode] progressMessage = "" } } diff --git a/Sources/SkillDeck/ViewModels/ProjectManager.swift b/Sources/SkillDeck/ViewModels/ProjectManager.swift new file mode 100644 index 0000000..bce9f39 --- /dev/null +++ b/Sources/SkillDeck/ViewModels/ProjectManager.swift @@ -0,0 +1,404 @@ +import Foundation +import Observation + +/// A user-selected project root. Projects are never discovered recursively. +struct ManagedProject: Codable, Hashable, Identifiable { + let rootPath: String + let displayName: String? + /// Optional preserves UserDefaults records written before the global-source flag existed. + let usesConfiguredGlobalSource: Bool? + + init(rootPath: String, displayName: String? = nil, usesConfiguredGlobalSource: Bool = false) { + self.rootPath = rootPath + self.displayName = displayName + self.usesConfiguredGlobalSource = usesConfiguredGlobalSource + } + + var id: String { rootPath } + var rootURL: URL { URL(fileURLWithPath: rootPath) } + var name: String { displayName ?? rootURL.lastPathComponent } + var sourceSkillsURL: URL { + usesConfiguredGlobalSource == true ? SkillStorageSettings.globalSkillsURL : rootURL.appendingPathComponent(".agents/skills") + } +} + +/// Project-local agent directories. These paths intentionally differ from AgentType, which models +/// paths under the user home directory. +struct ProjectAgent: Hashable, Identifiable { + let id: String + let name: String + let relativeSkillsPath: String + let createsSkillsDirectoryWhenMissing: Bool + /// Global synchronization resolves the path from AgentType so custom tool settings apply. + let globalAgentType: AgentType? + + init( + id: String, + name: String, + relativeSkillsPath: String, + createsSkillsDirectoryWhenMissing: Bool, + globalAgentType: AgentType? = nil + ) { + self.id = id + self.name = name + self.relativeSkillsPath = relativeSkillsPath + self.createsSkillsDirectoryWhenMissing = createsSkillsDirectoryWhenMissing + self.globalAgentType = globalAgentType + } + + func skillsURL(in project: ManagedProject) -> URL { + if project.usesConfiguredGlobalSource == true, let globalAgentType { + return globalAgentType.skillsDirectoryURL + } + return project.rootURL.appendingPathComponent(relativeSkillsPath) + } + + /// One complete tool registry drives the sidebar, project synchronization, and global synchronization. + /// Project targets use their relative configuration paths; global targets use each tool's configured path. + static let all: [ProjectAgent] = AgentType.allCases.map { agent in + .init( + id: agent.id, + name: agent.displayName, + relativeSkillsPath: agent.projectSkillsRelativePath, + createsSkillsDirectoryWhenMissing: true, + globalAgentType: agent + ) + } + + /// The global synchronization screen uses the same complete registry as project synchronization. + static let global: [ProjectAgent] = AgentType.allCases.map { agent in + .init( + id: agent.id, + name: agent.displayName, + relativeSkillsPath: agent.projectSkillsRelativePath, + createsSkillsDirectoryWhenMissing: true, + globalAgentType: agent + ) + } +} + +enum ProjectSkillState: String { + case linked, directoryLinked, missing, broken, foreignLink, occupied, rootConflict + + var label: String { + switch self { + case .linked: "已同步" + case .directoryLinked: "整目录已同步" + case .missing: "未同步" + case .broken: "失效软链" + case .foreignLink: "指向其他位置" + case .occupied: "已有真实文件" + case .rootConflict: "目标目录冲突" + } + } + + var isLinked: Bool { self == .linked || self == .directoryLinked } +} + +struct ProjectSkillRow: Identifiable, Hashable { + let name: String + let sourceURL: URL + let targetURL: URL + let state: ProjectSkillState + let detail: String + + var id: String { targetURL.path } +} + +struct ProjectInspection: Identifiable { + let project: ManagedProject + let agent: ProjectAgent + let targetRootIsDirectoryLink: Bool + let targetIsAvailable: Bool + let skills: [ProjectSkillRow] + + var id: String { project.id + "|" + agent.id } + var targetURL: URL { agent.skillsURL(in: project) } + var isWholeDirectorySynced: Bool { + targetRootIsDirectoryLink && !skills.isEmpty && skills.allSatisfy { $0.state == .directoryLinked } + } +} + +enum ProjectChangeKind: String { + case createDirectoryLink, replaceDirectory, removeDirectoryLink + var label: String { + switch self { + case .createDirectoryLink: "创建目录软链" + case .replaceDirectory: "备份后整体替换" + case .removeDirectoryLink: "移除目录软链" + } + } +} + +enum ProjectConflictResolution: String, CaseIterable, Identifiable { + case skip, backupAndReplace + var id: String { rawValue } + var label: String { self == .skip ? "跳过" : "备份后替换" } +} + +struct ProjectChange: Identifiable { + let id = UUID() + let kind: ProjectChangeKind + let sourceRoot: URL + let targetRoot: URL + let skillNames: [String] + let summary: String + let needsResolution: Bool + var resolution: ProjectConflictResolution = .skip +} + +/// ProjectSyncFileSystem is pure filesystem logic that can also be exercised with temporary folders. +struct ProjectSyncFileSystem { + let fileManager: FileManager + let backupRoot: URL + + init(fileManager: FileManager = .default, backupRoot: URL? = nil) { + self.fileManager = fileManager + self.backupRoot = backupRoot ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("SkillDeck/ProjectSyncBackups", isDirectory: true) + } + + func inspect(project: ManagedProject, agent: ProjectAgent) -> ProjectInspection { + let source = project.sourceSkillsURL + guard fileManager.fileExists(atPath: source.path) else { + return ProjectInspection(project: project, agent: agent, targetRootIsDirectoryLink: false, targetIsAvailable: false, skills: []) + } + let target = agent.skillsURL(in: project) + let rootLink = linkDestination(at: target) + let rootLinksSource = rootLink.map { sameLocation($0, source) } ?? false + let available = fileManager.fileExists(atPath: target.path) || agent.createsSkillsDirectoryWhenMissing || rootLink != nil + let names = (try? fileManager.contentsOfDirectory(at: source, includingPropertiesForKeys: nil)) ?? [] + let skills = names.compactMap { sourceItem -> ProjectSkillRow? in + let canonical = sourceItem.resolvingSymlinksInPath() + guard fileManager.fileExists(atPath: canonical.appendingPathComponent("SKILL.md").path) else { return nil } + let targetItem = target.appendingPathComponent(sourceItem.lastPathComponent) + let state: ProjectSkillState + let detail: String + if rootLinksSource { + state = .directoryLinked + detail = target.path + } else if rootLink != nil { + state = .rootConflict + detail = rootLink!.path + } else if let destination = linkDestination(at: targetItem) { + if sameLocation(destination, sourceItem) { state = .linked } else if fileManager.fileExists(atPath: targetItem.path) { state = .foreignLink } else { state = .broken } + detail = destination.path + } else if fileManager.fileExists(atPath: targetItem.path) { + state = .occupied + detail = targetItem.path + } else { + state = .missing + detail = targetItem.path + } + return ProjectSkillRow(name: sourceItem.lastPathComponent, sourceURL: sourceItem, targetURL: targetItem, state: state, detail: detail) + }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + return ProjectInspection(project: project, agent: agent, targetRootIsDirectoryLink: rootLink != nil, targetIsAvailable: available, skills: skills) + } + + /// Project sync always migrates the entire source skills directory to one target directory link. + /// Per-skill plans are intentionally unsupported because they create a second, inconsistent sync model. + func syncPlan(for inspection: ProjectInspection) -> [ProjectChange] { + guard inspection.targetIsAvailable, !inspection.skills.isEmpty else { return [] } + let source = inspection.project.sourceSkillsURL + let target = inspection.targetURL + let skillNames = inspection.skills.map(\.name) + if sameLocation(linkDestination(at: target) ?? target, source) { return [] } + if fileManager.fileExists(atPath: target.path) || linkDestination(at: target) != nil { + return [.init(kind: .replaceDirectory, sourceRoot: source, targetRoot: target, skillNames: skillNames, summary: "目标 skills 目录已有内容。备份后将整个目录改为直接指向项目 .agents/skills。", needsResolution: true)] + } + return [.init(kind: .createDirectoryLink, sourceRoot: source, targetRoot: target, skillNames: skillNames, summary: "创建整个 skills 目录软链,直接指向项目 .agents/skills。", needsResolution: false)] + } + + /// Removal is only available for the full directory link owned by project sync. + func removePlan(for inspection: ProjectInspection) -> [ProjectChange] { + guard inspection.targetRootIsDirectoryLink, + sameLocation(linkDestination(at: inspection.targetURL) ?? inspection.targetURL, inspection.project.sourceSkillsURL) else { + return [] + } + return [.init(kind: .removeDirectoryLink, sourceRoot: inspection.project.sourceSkillsURL, targetRoot: inspection.targetURL, skillNames: inspection.skills.map(\.name), summary: "只移除目标目录软链,不删除项目源技能。", needsResolution: false)] + } + + func apply(_ change: ProjectChange) throws -> String { + switch change.kind { + case .createDirectoryLink: + // The parent configuration folder may not exist yet for a known project agent. + try fileManager.createDirectory(at: change.targetRoot.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.createSymbolicLink(at: change.targetRoot, withDestinationURL: change.sourceRoot) + case .replaceDirectory: + guard change.resolution == .backupAndReplace else { return "保留目标 skills 目录" } + let backup = try backup(change.targetRoot) + try fileManager.createDirectory(at: change.targetRoot.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.createSymbolicLink(at: change.targetRoot, withDestinationURL: change.sourceRoot) + return "已备份到 \(backup.path)" + case .removeDirectoryLink: + try fileManager.removeItem(at: change.targetRoot) + } + return "已执行 \(change.kind.label)" + } + + private func linkDestination(at url: URL) -> URL? { + guard let value = try? fileManager.destinationOfSymbolicLink(atPath: url.path) else { return nil } + return URL(fileURLWithPath: value, relativeTo: url.deletingLastPathComponent()).standardizedFileURL + } + + private func sameLocation(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.resolvingSymlinksInPath().standardizedFileURL.path == rhs.resolvingSymlinksInPath().standardizedFileURL.path + } + + private func backup(_ item: URL) throws -> URL { + let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") + let folder = backupRoot.appendingPathComponent(stamp, isDirectory: true) + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + let destination = folder.appendingPathComponent(item.lastPathComponent + "-" + UUID().uuidString) + try fileManager.moveItem(at: item, to: destination) + return destination + } +} + +@MainActor +@Observable +final class ProjectManager { + private static let storageKey = "SkillDeck.managedProjects" + private let filesystem = ProjectSyncFileSystem() + private let fixedProject: ManagedProject? + var projects: [ManagedProject] = [] + var inspections: [ProjectInspection] = [] + var selectedProjectID: String? + var selectedTargetID: String? + var changes: [ProjectChange] = [] + var showsPreview = false + var applyErrorMessage: String? + var applyResultMessage: String? + var logs: [String] = [] + + /// Project-local targets and global AI tools intentionally use different path resolvers. + /// The global set is the sidebar's AgentType list; projects retain their local configuration paths. + var targets: [ProjectAgent] { + fixedProject?.usesConfiguredGlobalSource == true ? ProjectAgent.global : ProjectAgent.all + } + + /// Global sources are managed by SkillManager and can use its lock-file update workflow. + var isGlobalSync: Bool { fixedProject?.usesConfiguredGlobalSource == true } + + /// A fixed project reuses the same whole-directory sync engine for the global ~/.agents source. + init(fixedProject: ManagedProject? = nil) { + self.fixedProject = fixedProject + if let fixedProject { + projects = [fixedProject] + } else if let data = UserDefaults.standard.data(forKey: Self.storageKey), let saved = try? JSONDecoder().decode([ManagedProject].self, from: data) { + projects = saved.filter { FileManager.default.fileExists(atPath: $0.rootPath) } + } + selectedProjectID = projects.first?.id + selectedTargetID = targets.first?.id + } + + var selectedInspection: ProjectInspection? { inspections.first { $0.project.id == selectedProjectID && $0.agent.id == selectedTargetID } } + var canRemoveSelectedTarget: Bool { + guard let inspection = selectedInspection else { return false } + return !filesystem.removePlan(for: inspection).isEmpty + } + + /// Used by project rows and the visible target rail without exposing filesystem implementation details. + func inspection(projectID: String, targetID: String) -> ProjectInspection? { + inspections.first { $0.project.id == projectID && $0.agent.id == targetID } + } + + func addProject(_ url: URL) { + guard fixedProject == nil else { return } + let project = ManagedProject(rootPath: url.standardizedFileURL.path) + guard !projects.contains(project) else { return } + projects.append(project) + projects.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + selectedProjectID = project.id + selectedTargetID = targets.first?.id + save(); reload(); log("已添加项目 \(project.rootPath)") + } + + func removeSelectedProject() { + guard fixedProject == nil else { return } + guard let id = selectedProjectID else { return } + projects.removeAll { $0.id == id } + selectedProjectID = projects.first?.id + save(); reload(); log("已移除项目 \(id)") + } + + func remove(projectID: String) { + selectedProjectID = projectID + removeSelectedProject() + } + + func reload() { + inspections = projects.flatMap { project in targets.map { filesystem.inspect(project: project, agent: $0) } } + log("已扫描 \(projects.count) 个项目") + } + + func select(project: String?, target: String?) { + selectedProjectID = project + selectedTargetID = target + } + + func previewSync() { + guard let inspection = selectedInspection else { return } + changes = filesystem.syncPlan(for: inspection) + showsPreview = !changes.isEmpty + log("同步预览 \(changes.count) 项") + } + + /// Build one confirmation plan for every unsynchronized tool of the selected source. + /// Each target remains a separate directory migration so conflicts can still be backed up safely. + func previewSyncAll() { + guard let projectID = selectedProjectID else { return } + changes = inspections + .filter { $0.project.id == projectID } + .flatMap { filesystem.syncPlan(for: $0) } + showsPreview = !changes.isEmpty + log("全部同步预览 \(changes.count) 项") + } + func previewRemoval() { guard let inspection = selectedInspection else { return }; changes = filesystem.removePlan(for: inspection); showsPreview = !changes.isEmpty; log("移除预览 \(changes.count) 项") } + + /// Applies the whole-directory migration after its explicit confirmation action. + func applyChanges(replacingExistingDirectory: Bool = false) { + applyErrorMessage = nil + applyResultMessage = nil + var failures: [String] = [] + var results: [String] = [] + + for index in changes.indices where changes[index].needsResolution && replacingExistingDirectory { + changes[index].resolution = .backupAndReplace + } + + for change in changes { + do { + let result = try filesystem.apply(change) + log(result) + results.append(result) + } catch { + let message = error.localizedDescription + log("失败:\(message)") + failures.append(message) + } + } + + reload() + + guard failures.isEmpty else { + applyErrorMessage = failures.joined(separator: "\n") + return + } + + applyResultMessage = results.joined(separator: "\n") + } + + /// Clears the completed preview only after the user has seen the result message. + func finishApplyingChanges() { + changes = [] + showsPreview = false + applyResultMessage = nil + } + + private func save() { + guard fixedProject == nil, let data = try? JSONEncoder().encode(projects) else { return } + UserDefaults.standard.set(data, forKey: Self.storageKey) + } + private func log(_ message: String) { logs.insert("[项目同步] \(message)", at: 0); if logs.count > 100 { logs.removeLast() } } +} diff --git a/Sources/SkillDeck/ViewModels/ProjectSkillDetailViewModel.swift b/Sources/SkillDeck/ViewModels/ProjectSkillDetailViewModel.swift new file mode 100644 index 0000000..d627c4c --- /dev/null +++ b/Sources/SkillDeck/ViewModels/ProjectSkillDetailViewModel.swift @@ -0,0 +1,89 @@ +import Observation + +/// ProjectSkillDetailViewModel keeps update state isolated per project skill. +/// Unlike SkillDetailViewModel, it never reads or writes the user's global skill metadata. +@MainActor +@Observable +final class ProjectSkillDetailViewModel { + let service: ProjectSkillUpdateService + let row: ProjectSkillRow + var skill: Skill? + var isLoading = false + var isLinking = false + var isChecking = false + var isUpdating = false + var repositoryInput = "" + var remoteHash: String? + var hasUpdate = false + var errorMessage: String? + /// A successful operation needs persistent visible feedback; project updates previously only + /// changed button state, which made a completed check indistinguishable from no action. + var statusMessage: String? + + init(row: ProjectSkillRow) { + self.row = row + self.service = ProjectSkillUpdateService(sourceRoot: row.sourceURL.deletingLastPathComponent()) + } + + func load() async { + isLoading = true + defer { isLoading = false } + do { + skill = try await service.loadSkill(named: row.name, at: row.sourceURL) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + func linkRepository() async { + guard let skill else { return } + let input = repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !input.isEmpty else { return } + isLinking = true + defer { isLinking = false } + do { + self.skill = try await service.link(skill: skill, repositoryInput: input) + repositoryInput = "" + remoteHash = nil + hasUpdate = false + errorMessage = nil + statusMessage = "已关联仓库,并已同步当前项目技能" + } catch { + errorMessage = error.localizedDescription + statusMessage = nil + } + } + + func checkForUpdate() async { + guard let skill else { return } + isChecking = true + defer { isChecking = false } + do { + let result = try await service.checkForUpdate(skill: skill) + remoteHash = result.remoteHash + hasUpdate = result.hasUpdate + errorMessage = nil + statusMessage = result.hasUpdate ? "发现可用更新" : "当前已是最新版本" + } catch { + errorMessage = error.localizedDescription + statusMessage = nil + } + } + + func update() async { + guard let skill, let remoteHash else { return } + isUpdating = true + defer { isUpdating = false } + do { + self.skill = try await service.update(skill: skill, remoteHash: remoteHash) + self.remoteHash = nil + hasUpdate = false + errorMessage = nil + statusMessage = "项目技能已更新到最新版本" + } catch { + errorMessage = error.localizedDescription + statusMessage = nil + } + } +} diff --git a/Sources/SkillDeck/ViewModels/SkillDetailViewModel.swift b/Sources/SkillDeck/ViewModels/SkillDetailViewModel.swift index 04d5c92..d2efc1f 100644 --- a/Sources/SkillDeck/ViewModels/SkillDetailViewModel.swift +++ b/Sources/SkillDeck/ViewModels/SkillDetailViewModel.swift @@ -49,18 +49,6 @@ final class SkillDetailViewModel { skillManager.skills.first { $0.id == id } } - /// Toggle Agent assignment status - func toggleAgent(_ agentType: AgentType, for skill: Skill) async { - print("[SkillDetailViewModel] toggleAgent called for \(agentType.displayName), skill: \(skill.id)") - do { - try await skillManager.toggleAssignment(skill, agent: agentType) - feedbackMessage = nil - } catch { - print("[SkillDetailViewModel] toggleAgent error: \(error.localizedDescription)") - feedbackMessage = error.localizedDescription - } - } - /// Reveal skill directory in Finder /// NSWorkspace is the system interaction class provided by macOS AppKit framework func revealInFinder(skill: Skill) { diff --git a/Sources/SkillDeck/ViewModels/SkillInstallViewModel.swift b/Sources/SkillDeck/ViewModels/SkillInstallViewModel.swift index eca77c2..227e5d2 100644 --- a/Sources/SkillDeck/ViewModels/SkillInstallViewModel.swift +++ b/Sources/SkillDeck/ViewModels/SkillInstallViewModel.swift @@ -4,7 +4,7 @@ import Foundation /// /// Installation flow consists of two steps: /// 1. User inputs GitHub repository URL → shallow clone → scan for skills → display list -/// 2. User selects skills and Agents to install → execute installation → complete +/// 2. User selects skills to install into the canonical global directory → execute installation → complete /// /// @MainActor ensures all properties update on the main thread (UI-bound state must be on main thread) /// @Observable enables SwiftUI to automatically track property changes and refresh views @@ -89,9 +89,6 @@ final class SkillInstallViewModel: Identifiable { /// Set provides O(1) lookup, similar to Java's HashSet var selectedSkillPaths: Set = [] - /// Set of target Agents selected by user (Claude Code selected by default) - var selectedAgents: Set = [.claudeCode] - /// Set of already installed skill names (used to mark "already installed" in the list) var alreadyInstalledNames: Set = [] @@ -251,8 +248,7 @@ final class SkillInstallViewModel: Identifiable { from: repoDir, skill: skill, repoSource: normalizedSource, - repoURL: normalizedRepoURL, - targetAgents: selectedAgents + repoURL: normalizedRepoURL ) installedCount += 1 } catch { @@ -306,15 +302,6 @@ final class SkillInstallViewModel: Identifiable { } } - /// Toggle selection state of an Agent - func toggleAgentSelection(_ agent: AgentType) { - if selectedAgents.contains(agent) { - selectedAgents.remove(agent) - } else { - selectedAgents.insert(agent) - } - } - /// Reset to initial state (start over) func reset() { cleanup() @@ -322,7 +309,6 @@ final class SkillInstallViewModel: Identifiable { repoURLInput = "" discoveredSkills = [] selectedSkillPaths = [] - selectedAgents = [.claudeCode] alreadyInstalledNames = [] progressMessage = "" installedCount = 0 diff --git a/Sources/SkillDeck/Views/Components/AgentToggleView.swift b/Sources/SkillDeck/Views/Components/AgentToggleView.swift deleted file mode 100644 index 89a0210..0000000 --- a/Sources/SkillDeck/Views/Components/AgentToggleView.swift +++ /dev/null @@ -1,124 +0,0 @@ -import SwiftUI - -/// AgentToggleView displays installation status toggles for skill on each Agent (F06) -/// -/// One Toggle per Agent (switch), creates symlink when on, deletes when off -/// Toggle for inherited installation (isInherited) shows as ON but disabled, with source hint -/// For Codex: skill in ~/.agents/skills/ is native support, always ON and disabled -struct AgentToggleView: View { - - let skillID: String - let viewModel: SkillDetailViewModel - @Environment(SkillManager.self) private var skillManager - - var body: some View { - VStack(spacing: 8) { - // Iterate through all detected Agents - ForEach(AgentType.allCases) { agentType in - AgentToggleRow( - skillID: skillID, - agentType: agentType, - viewModel: viewModel - ) - } - } - } -} - -/// Single row for an Agent toggle -/// Uses skillID instead of Skill to ensure proper SwiftUI dependency tracking -private struct AgentToggleRow: View { - let skillID: String - let agentType: AgentType - let viewModel: SkillDetailViewModel - @Environment(SkillManager.self) private var skillManager - - /// Get the latest skill data from SkillManager to ensure real-time updates - /// Accessing skillManager.skills here ensures SwiftUI tracks this dependency - private var skill: Skill? { - skillManager.skills.first { $0.id == skillID } - } - - /// Find installation record for this Agent - private var installation: SkillInstallation? { - skill?.installations.first { $0.agentType == agentType } - } - - /// Whether this Agent has the skill installed - private var isInstalled: Bool { - installation != nil - } - - /// Check if this is an inherited installation (from another Agent's directory) - /// Uses isTrulyInherited which treats Codex's ~/.agents/skills/ as non-inherited - private var isInherited: Bool { - installation?.isTrulyInherited ?? false - } - - /// Check if this is Codex native support (skill in ~/.agents/skills/) - /// Native support means the skill is in Codex's official user-level directory - private var isCodexNativeSupport: Bool { - guard agentType == .codex else { return false } - guard let installation = installation else { return false } - // Native support: skill is in ~/.agents/skills/ (Codex's official directory) - return installation.inheritedFrom == .codex - } - - /// Whether the Agent is available (installed or config exists) - private var isAgentAvailable: Bool { - let agent = skillManager.agents.first { $0.type == agentType } - return agent?.isInstalled == true || agent?.configDirectoryExists == true - } - - /// Whether the toggle should be disabled - private var isToggleDisabled: Bool { - // Disabled conditions: - // - Inherited installation (need to modify at source Agent) - // - Agent not installed and skill not installed - // - Codex native support (always on, cannot be toggled) - isInherited || isCodexNativeSupport || (!isAgentAvailable && !isInstalled) - } - - var body: some View { - HStack { - Image(systemName: agentType.iconName) - .foregroundStyle(Constants.AgentColors.color(for: agentType)) - .frame(width: 20) - - Text(agentType.displayName) - - Spacer() - - // Hint text for special states - if isCodexNativeSupport { - // Codex native support: skill is in ~/.agents/skills/ - Text("Native support").appFont(.caption) - .foregroundStyle(.secondary) - } else if isInherited, let installation { - // Inherited installation: shows source path like "via ~/.claude/skills" - Text("via \(installation.parentDirectoryDisplayPath)").appFont(.caption) - .foregroundStyle(.secondary) - } else if !isAgentAvailable && !isInstalled { - Text("Not installed").appFont(.caption) - .foregroundStyle(.tertiary) - } - - // Toggle switch - Toggle("", isOn: Binding( - get: { isInstalled }, - set: { _ in - Task { - // Pass the current skill from SkillManager, not the captured value - if let skill = skillManager.skills.first(where: { $0.id == skillID }) { - await viewModel.toggleAgent(agentType, for: skill) - } - } - } - )) - .toggleStyle(.switch) - .labelsHidden() - .disabled(isToggleDisabled) - } - .padding(.vertical, 2) - } -} diff --git a/Sources/SkillDeck/Views/ContentView.swift b/Sources/SkillDeck/Views/ContentView.swift index fa0be03..82311fc 100644 --- a/Sources/SkillDeck/Views/ContentView.swift +++ b/Sources/SkillDeck/Views/ContentView.swift @@ -21,6 +21,13 @@ struct ContentView: View { /// Currently selected sidebar item @State private var selectedSidebarItem: SidebarItem? = .dashboard + /// ProjectManager is independent from SkillManager because project skills must not be + /// deduplicated with global skills that happen to share the same directory name. + @State private var projectManager: ProjectManager? + + /// Global sync uses the same manager with the user home directory as a fixed project root. + @State private var globalSyncManager: ProjectManager? + /// Currently selected skill ID (used for navigation to detail page) @State private var selectedSkillID: String? @@ -48,7 +55,17 @@ struct ContentView: View { } content: { // Middle column: content varies based on sidebar selection // F09: When "Registry" is selected, show RegistryBrowserView instead of DashboardView - if selectedSidebarItem == .registry { + if selectedSidebarItem == .globalSync { + if let globalSyncManager { + GlobalSkillSourceList(manager: globalSyncManager) + .navigationSplitViewColumnWidth(min: 280, ideal: 340, max: 480) + } + } else if selectedSidebarItem == .projects { + if let projectManager { + ProjectTargetList(manager: projectManager) + .navigationSplitViewColumnWidth(min: 280, ideal: 340, max: 480) + } + } else if selectedSidebarItem == .registry { // F09: Registry browser — browse and search skills.sh catalog if let vm = registryVM { RegistryBrowserView(viewModel: vm) @@ -71,7 +88,19 @@ struct ContentView: View { } } detail: { // Right column: detail view varies based on sidebar selection - if selectedSidebarItem == .registry { + if selectedSidebarItem == .globalSync { + if let globalSyncManager { + ProjectSyncDetail(manager: globalSyncManager) + } else { + EmptyStateView(icon: "link", title: "全局同步", subtitle: "正在准备全局同步管理器") + } + } else if selectedSidebarItem == .projects { + if let projectManager { + ProjectSyncDetail(manager: projectManager) + } else { + EmptyStateView(icon: "folder", title: "项目", subtitle: "正在准备项目管理器") + } + } else if selectedSidebarItem == .registry { // F09: Show registry skill detail when a registry skill is selected if let vm = registryVM, let skill = vm.selectedSkill { RegistrySkillDetailView( @@ -117,6 +146,12 @@ struct ContentView: View { .task { dashboardVM = DashboardViewModel(skillManager: skillManager) detailVM = SkillDetailViewModel(skillManager: skillManager) + let manager = ProjectManager() + manager.reload() + projectManager = manager + let globalManager = ProjectManager(fixedProject: ManagedProject(rootPath: NSHomeDirectory(), displayName: "全局技能", usesConfiguredGlobalSource: true)) + globalManager.reload() + globalSyncManager = globalManager // F09: Initialize registry browser ViewModel registryVM = RegistryBrowserViewModel(skillManager: skillManager) clawHubVM = ClawHubBrowserViewModel(skillManager: skillManager) diff --git a/Sources/SkillDeck/Views/Detail/SkillDetailView.swift b/Sources/SkillDeck/Views/Detail/SkillDetailView.swift index c6e1bf3..3664e65 100644 --- a/Sources/SkillDeck/Views/Detail/SkillDetailView.swift +++ b/Sources/SkillDeck/Views/Detail/SkillDetailView.swift @@ -42,11 +42,6 @@ struct SkillDetailView: View { Divider() - // Agent assignment section - agentAssignmentSection() - - Divider() - // Markdown body markdownSection(skill) } @@ -185,16 +180,6 @@ struct SkillDetailView: View { } } - /// Agent assignment section (F06) - @ViewBuilder - private func agentAssignmentSection() -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("Agent Assignment").appFont(.headline) - - AgentToggleView(skillID: skillID, viewModel: viewModel) - } - } - /// Markdown body section @ViewBuilder private func markdownSection(_ skill: Skill) -> some View { diff --git a/Sources/SkillDeck/Views/Install/LocalImportView.swift b/Sources/SkillDeck/Views/Install/LocalImportView.swift index 622a023..653b115 100644 --- a/Sources/SkillDeck/Views/Install/LocalImportView.swift +++ b/Sources/SkillDeck/Views/Install/LocalImportView.swift @@ -158,32 +158,8 @@ struct LocalImportView: View { Divider() - // Agent selection area + Import button + // Import always writes to the canonical global skills directory. VStack(spacing: 12) { - // Agent selection grid (same layout as SkillInstallView) - VStack(alignment: .leading, spacing: 8) { - Text("Install to:").appFont(.subheadline) - .foregroundStyle(.secondary) - - // LazyVGrid adapts column width, automatically wraps based on available space - // adaptive(minimum: 120) means each column is at least 120pt - LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), alignment: .leading)], alignment: .leading, spacing: 8) { - ForEach(AgentType.allCases) { agentType in - let isDetected = skillManager.agents.first { $0.type == agentType }?.isInstalled == true - // Toggle is macOS checkbox component - Toggle(isOn: Binding( - get: { viewModel.selectedAgents.contains(agentType) }, - set: { _ in viewModel.toggleAgentSelection(agentType) } - )) { - Label(agentType.displayName, systemImage: agentType.iconName).appFont(.caption) - } - .toggleStyle(.checkbox) - // Uninstalled Agents have reduced opacity but are still selectable - .opacity(isDetected ? 1.0 : 0.5) - } - } - } - // Import button HStack { Spacer() @@ -191,7 +167,6 @@ struct LocalImportView: View { Button("Import") { Task { await viewModel.importSkill() } } - .disabled(viewModel.selectedAgents.isEmpty) .buttonStyle(.borderedProminent) } } diff --git a/Sources/SkillDeck/Views/Install/SkillInstallView.swift b/Sources/SkillDeck/Views/Install/SkillInstallView.swift index c7d6450..20aec20 100644 --- a/Sources/SkillDeck/Views/Install/SkillInstallView.swift +++ b/Sources/SkillDeck/Views/Install/SkillInstallView.swift @@ -205,39 +205,8 @@ struct SkillInstallView: View { Divider() - // Agent selection area + install button + // Installation always writes to the canonical global skills directory. VStack(spacing: 12) { - // Agent selection area (two-row layout to avoid horizontal squeezing) - VStack(alignment: .leading, spacing: 8) { - Text("Install to:").appFont(.subheadline) - .foregroundStyle(.secondary) - - // LazyVGrid adapts column width, automatically wraps based on available space - // adaptive(minimum: 120) means each column is at least 120pt, extra space is automatically distributed - LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), alignment: .leading)], alignment: .leading, spacing: 8) { - // ForEach iterates through all detected Agents - ForEach(AgentType.allCases) { agentType in - let isDetected = skillManager.agents.first { $0.type == agentType }?.isInstalled == true - // Toggle is macOS switch/checkbox component - Toggle(isOn: Binding( - get: { viewModel.selectedAgents.contains(agentType) }, - set: { newValue in - if newValue { - viewModel.selectedAgents.insert(agentType) - } else { - viewModel.selectedAgents.remove(agentType) - } - } - )) { - Label(agentType.displayName, systemImage: agentType.iconName).appFont(.caption) - } - .toggleStyle(.checkbox) - // Uninstalled Agents have reduced opacity but are still selectable - .opacity(isDetected ? 1.0 : 0.5) - } - } - } - // Install button HStack { // Selected count hint @@ -250,7 +219,7 @@ struct SkillInstallView: View { Button("Install") { Task { await viewModel.installSelected() } } - .disabled(viewModel.selectedSkillPaths.isEmpty || viewModel.selectedAgents.isEmpty) + .disabled(viewModel.selectedSkillPaths.isEmpty) // .buttonStyle(.borderedProminent) makes button display filled prominent color style .buttonStyle(.borderedProminent) } diff --git a/Sources/SkillDeck/Views/Projects/GlobalSkillSourceList.swift b/Sources/SkillDeck/Views/Projects/GlobalSkillSourceList.swift new file mode 100644 index 0000000..94ec294 --- /dev/null +++ b/Sources/SkillDeck/Views/Projects/GlobalSkillSourceList.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// GlobalSkillSourceList is the fixed source counterpart to ProjectTargetList. +/// It exposes the configured global skills directory as one source row, while ProjectSyncDetail manages +/// the same whole-directory migration workflow used by project-local skills. +struct GlobalSkillSourceList: View { + @Bindable var manager: ProjectManager + + var body: some View { + List(manager.projects, selection: $manager.selectedProjectID) { source in + VStack(alignment: .leading, spacing: 6) { + HStack { + Label(source.name, systemImage: "tray.full") + .appFont(.headline) + Spacer() + Text("\(sourceSkillCount) 个技能") + .appFont(.caption) + .foregroundStyle(.secondary) + } + Text(source.sourceSkillsURL.tildeAbbreviatedPath) + .appFont(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + Label("\(syncedTargetCount)/\(manager.targets.count) 个工具已整体同步", systemImage: "link") + .appFont(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .tag(Optional(source.id)) + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + .navigationTitle("全局同步") + .toolbar { + ToolbarItem { + Button { manager.reload() } label: { Image(systemName: "arrow.clockwise") } + .help("重新扫描全局技能") + } + } + .onChange(of: manager.selectedProjectID) { _, projectID in + manager.select(project: projectID, target: manager.selectedTargetID) + } + .onAppear { manager.reload() } + } + + private var inspections: [ProjectInspection] { + guard let sourceID = manager.selectedProjectID else { return [] } + return manager.inspections.filter { $0.project.id == sourceID } + } + + private var sourceSkillCount: Int { + inspections.first?.skills.count ?? 0 + } + + private var syncedTargetCount: Int { + inspections.filter(\.isWholeDirectorySynced).count + } +} diff --git a/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift b/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift new file mode 100644 index 0000000..ab66400 --- /dev/null +++ b/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift @@ -0,0 +1,341 @@ +import AppKit +import SwiftUI + +/// The middle column mirrors DashboardView: it is a list of first-class projects, not a picker +/// combined with a second list. Selecting a row drives the whole right-hand project workspace. +struct ProjectTargetList: View { + @Bindable var manager: ProjectManager + + var body: some View { + Group { + if manager.projects.isEmpty { + ContentUnavailableView( + "尚未添加项目", + systemImage: "folder.badge.plus", + description: Text("添加一个项目根目录后,SkillDeck 只会扫描其直接下的 .agents/skills。") + ) + } else { + List(manager.projects, selection: $manager.selectedProjectID) { project in + ProjectRow(project: project, manager: manager) + .tag(Optional(project.id)) + .contextMenu { + Button("在 Finder 中显示") { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: project.rootPath) + } + Divider() + Button("移除项目", role: .destructive) { + manager.remove(projectID: project.id) + } + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + } + .navigationTitle("项目") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: chooseProject) { + Image(systemName: "plus") + } + .help("添加项目") + } + ToolbarItem { + Button { manager.reload() } label: { Image(systemName: "arrow.clockwise") } + .help("重新扫描项目") + } + } + .onChange(of: manager.selectedProjectID) { _, projectID in + manager.select(project: projectID, target: manager.selectedTargetID) + } + } + + private func chooseProject() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.message = "选择项目根目录" + if panel.runModal() == .OK, let url = panel.url { + manager.addProject(url) + } + } +} + +/// ProjectRow uses the same compact hierarchy as SkillRowView: name, source location, then status. +private struct ProjectRow: View { + let project: ManagedProject + let manager: ProjectManager + + private var inspections: [ProjectInspection] { + manager.inspections.filter { $0.project.id == project.id } + } + + private var sourceCount: Int { + inspections.first?.skills.count ?? 0 + } + + private var fullySyncedTargets: Int { + inspections.filter { inspection in + inspection.isWholeDirectorySynced + }.count + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Label(project.name, systemImage: "folder") + .appFont(.headline) + Spacer() + Text("\(sourceCount) 个技能") + .appFont(.caption) + .foregroundStyle(.secondary) + } + Text(project.rootURL.tildeAbbreviatedPath) + .appFont(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + Label("\(fullySyncedTargets)/\(manager.targets.count) 个工具已完全同步", systemImage: "link") + .appFont(.caption) + .foregroundStyle(fullySyncedTargets == manager.targets.count && sourceCount > 0 ? .green : .secondary) + } + .padding(.vertical, 4) + } +} + +/// The detail pane has a visible agent status rail. It avoids a second navigation list while +/// keeping target selection direct and discoverable for projects with many configured agents. +struct ProjectSyncDetail: View { + @Bindable var manager: ProjectManager + @Environment(SkillManager.self) private var skillManager + @State private var selectedSourceSkill: ProjectSkillRow? + + var body: some View { + if let project = manager.projects.first(where: { $0.id == manager.selectedProjectID }), + let inspection = manager.selectedInspection { + VStack(alignment: .leading, spacing: 0) { + projectHeader(project) + agentRail(project) + Divider() + skillsContent(inspection) + Divider() + actionBar(inspection) + } + .navigationTitle(project.name) + .sheet(isPresented: $manager.showsPreview) { + ProjectChangePreview(manager: manager) + } + .sheet(item: $selectedSourceSkill) { skill in + if manager.isGlobalSync, + skillManager.skills.contains(where: { $0.id == skill.name && $0.canonicalURL.standardizedFileURL == skill.sourceURL.standardizedFileURL }) { + SkillDetailView( + skillID: skill.name, + viewModel: SkillDetailViewModel(skillManager: skillManager) + ) + .frame(minWidth: 760, minHeight: 620) + } else { + ProjectSkillDetailView(row: skill) + .frame(minWidth: 680, minHeight: 560) + } + } + } else { + ContentUnavailableView("选择项目", systemImage: "folder", description: Text("在中间列表中选择一个项目,查看它的技能同步状态。")) + } + } + + private func projectHeader(_ project: ManagedProject) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(project.name).appFont(.title2).fontWeight(.bold) + Text(project.rootURL.tildeAbbreviatedPath).appFont(.caption).foregroundStyle(.secondary).textSelection(.enabled) + Text("源:\(project.sourceSkillsURL.tildeAbbreviatedPath)").appFont(.caption).foregroundStyle(.tertiary).textSelection(.enabled) + } + .padding() + } + + private func agentRail(_ project: ManagedProject) -> some View { + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(manager.targets) { agent in + let targetInspection = manager.inspection(projectID: project.id, targetID: agent.id) + let isSelected = manager.selectedTargetID == agent.id + Button { + manager.select(project: project.id, target: agent.id) + } label: { + HStack(spacing: 5) { + Image(systemName: targetInspection?.isWholeDirectorySynced == true ? "checkmark.circle.fill" : "circle") + Text(agent.name) + Text(targetInspection.map { $0.isWholeDirectorySynced ? "\($0.skills.count)/\($0.skills.count)" : "0/\($0.skills.count)" } ?? "-") + .monospacedDigit() + } + .appFont(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .foregroundStyle(isSelected ? .white : .primary) + .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.12), in: Capsule()) + } + .buttonStyle(.plain) + .help(agent.relativeSkillsPath) + } + } + .padding(.horizontal) + .padding(.bottom, 10) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func skillsContent(_ inspection: ProjectInspection) -> some View { + if !inspection.targetIsAvailable { + ContentUnavailableView( + "未检测到项目源技能", + systemImage: "folder.badge.questionmark", + description: Text("在项目 .agents/skills 下添加包含 SKILL.md 的技能后即可整体同步。") + ) + } else if inspection.skills.isEmpty { + ContentUnavailableView( + "没有项目技能", + systemImage: "square.stack.3d.up", + description: Text("在项目 .agents/skills 下添加包含 SKILL.md 的技能。") + ) + } else { + List(inspection.skills) { skill in + Button { + selectedSourceSkill = skill + } label: { + HStack(spacing: 10) { + Image(systemName: "puzzlepiece.extension") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 3) { + Text(skill.name).appFont(.headline) + Text(manager.isGlobalSync ? "全局源技能,点击查看和更新" : "项目技能,点击查看和更新") + .appFont(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + if !manager.isGlobalSync { + Image(systemName: "arrow.up.circle") + .foregroundStyle(.secondary) + .help("查看和更新项目技能") + } + Image(systemName: "chevron.right") + .appFont(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 3) + } + .buttonStyle(.plain) + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + } + + private func actionBar(_ inspection: ProjectInspection) -> some View { + HStack { + Button("同步全部工具") { manager.previewSyncAll() } + .buttonStyle(.borderedProminent) + Button("同步整个 skills 目录") { manager.previewSync() } + Button("移除同步目录链接", role: .destructive) { manager.previewRemoval() } + .disabled(!manager.canRemoveSelectedTarget) + Spacer() + Text("源技能 \(inspection.skills.count) 项") + .appFont(.caption) + .foregroundStyle(.secondary) + } + .padding() + } +} + +private struct ProjectChangePreview: View { + @Bindable var manager: ProjectManager + @Environment(\.dismiss) private var dismiss + + private var sourcePath: String { + manager.changes.first?.sourceRoot.tildeAbbreviatedPath ?? "-" + } + + private var primaryActionLabel: String { + if manager.changes.count > 1 { + return manager.changes.contains(where: \.needsResolution) + ? "备份后同步 \(manager.changes.count) 个工具" + : "同步 \(manager.changes.count) 个工具" + } + return switch manager.changes.first?.kind { + case .replaceDirectory: "备份后整体替换" + case .removeDirectoryLink: "移除目录软链" + case .createDirectoryLink, nil: "创建目录软链" + } + } + + var body: some View { + VStack(spacing: 0) { + // This header matches the app's existing import sheets: compact title, close control, and a divider. + HStack { + Text("项目同步变更").appFont(.headline) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("关闭") + } + .padding() + + Divider() + + Text("同步会将每个目标 skills 目录指向:\(sourcePath)") + .appFont(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding([.horizontal, .top]) + + if !manager.changes.isEmpty { + List(manager.changes) { change in + VStack(alignment: .leading, spacing: 4) { + Text("\(change.kind.label):\(change.targetRoot.tildeAbbreviatedPath)") + .appFont(.headline) + Text("包含 \(change.skillNames.count) 个源技能") + .appFont(.caption) + .foregroundStyle(.secondary) + Text(change.summary).appFont(.caption).foregroundStyle(.secondary) + } + .padding(.vertical, 3) + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + + Divider() + + HStack { + Button("取消") { dismiss() } + Spacer() + Button(primaryActionLabel) { + manager.applyChanges(replacingExistingDirectory: manager.changes.contains(where: \.needsResolution)) + } + .buttonStyle(.borderedProminent) + } + .padding() + } + .frame(minWidth: 560, idealWidth: 680, maxWidth: 820, minHeight: 320, maxHeight: 620) + .alert("同步失败", isPresented: Binding( + get: { manager.applyErrorMessage != nil }, + set: { if !$0 { manager.applyErrorMessage = nil } } + )) { + Button("好", role: .cancel) { + manager.applyErrorMessage = nil + } + } message: { + Text(manager.applyErrorMessage ?? "") + } + .alert("同步结果", isPresented: Binding( + get: { manager.applyResultMessage != nil }, + set: { if !$0 { manager.applyResultMessage = nil } } + )) { + Button("完成") { + manager.finishApplyingChanges() + dismiss() + } + } message: { + Text(manager.applyResultMessage ?? "") + } + } +} diff --git a/Sources/SkillDeck/Views/Projects/ProjectSkillDetailView.swift b/Sources/SkillDeck/Views/Projects/ProjectSkillDetailView.swift new file mode 100644 index 0000000..68078d7 --- /dev/null +++ b/Sources/SkillDeck/Views/Projects/ProjectSkillDetailView.swift @@ -0,0 +1,158 @@ +import AppKit +import SwiftUI + +/// ProjectSkillDetailView presents a project-local skill and its independent Git update lifecycle. +/// Its state comes from ProjectSkillDetailViewModel so actions always stay within the selected project. +struct ProjectSkillDetailView: View { + let row: ProjectSkillRow + @State private var viewModel: ProjectSkillDetailViewModel + + init(row: ProjectSkillRow) { + self.row = row + _viewModel = State(initialValue: ProjectSkillDetailViewModel(row: row)) + } + + var body: some View { + Group { + if let skill = viewModel.skill { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header(skill) + Divider() + repositorySection(skill) + Divider() + MarkdownContentView(markdownText: skill.markdownBody, showsChineseTranslation: false) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(8) + } + .padding() + } + .navigationTitle(skill.displayName) + .toolbar { toolbar } + } else if viewModel.isLoading { + ProgressView() + } else { + ContentUnavailableView( + "无法读取 SKILL.md", + systemImage: "exclamationmark.triangle", + description: Text(viewModel.errorMessage ?? row.sourceURL.path) + ) + } + } + .task { await viewModel.load() } + } + + private func header(_ skill: Skill) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(skill.displayName).appFont(.title2).fontWeight(.bold) + ScopeBadge(scope: skill.scope) + } + if !skill.metadata.description.isEmpty { + Text(skill.metadata.description).appFont(.body).foregroundStyle(.secondary) + } + Text(skill.canonicalURL.tildeAbbreviatedPath) + .appFont(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + + @ViewBuilder + private func repositorySection(_ skill: Skill) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("项目更新").appFont(.headline) + if let entry = skill.lockEntry { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + GridRow { + Text("来源").foregroundStyle(.secondary) + Text(entry.source).textSelection(.enabled) + } + GridRow { + Text("仓库").foregroundStyle(.secondary) + Text(entry.sourceUrl).textSelection(.enabled) + } + GridRow { + Text("更新于").foregroundStyle(.secondary) + Text(entry.updatedAt.formattedDate) + } + } + .appFont(.subheadline) + + HStack(spacing: 8) { + if viewModel.isChecking || viewModel.isUpdating { + ProgressView().controlSize(.small) + } + if viewModel.hasUpdate { + Label("发现更新", systemImage: "arrow.up.circle.fill") + .foregroundStyle(.orange) + Button("更新") { Task { await viewModel.update() } } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isUpdating) + } else { + Button("检查更新") { Task { await viewModel.checkForUpdate() } } + .disabled(viewModel.isChecking) + } + } + } else { + HStack(spacing: 8) { + TextField("owner/repo", text: $viewModel.repositoryInput) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await viewModel.linkRepository() } } + .disabled(viewModel.isLinking) + Button("关联仓库") { Task { await viewModel.linkRepository() } } + .disabled(viewModel.repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLinking) + } + } + if let errorMessage = viewModel.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .appFont(.caption) + .foregroundStyle(.orange) + } + if let statusMessage = viewModel.statusMessage { + Label(statusMessage, systemImage: viewModel.hasUpdate ? "arrow.up.circle.fill" : "checkmark.circle.fill") + .appFont(.caption) + .foregroundStyle(viewModel.hasUpdate ? .orange : .green) + } + } + } + + private var toolbar: some ToolbarContent { + ToolbarItemGroup { + Button { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: row.sourceURL.path) + } label: { + Image(systemName: "folder") + } + .help("在 Finder 中显示") + + Button { openInTerminal() } label: { + Image(systemName: "terminal") + } + .help("在终端中打开") + + Button { + NSWorkspace.shared.open(row.sourceURL.appendingPathComponent("SKILL.md")) + } label: { + Image(systemName: "pencil") + } + .help("用默认编辑器打开 SKILL.md") + } + } + + /// A directory must be opened with a terminal app explicitly; NSWorkspace.open alone would show Finder. + private func openInTerminal() { + for bundleID in ["dev.warp.Warp-Stable", "dev.warp.Warp", "com.googlecode.iterm2", "com.apple.Terminal"] { + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + NSWorkspace.shared.open( + [row.sourceURL], + withApplicationAt: appURL, + configuration: NSWorkspace.OpenConfiguration() + ) { _, _ in } + return + } + } + } +} diff --git a/Sources/SkillDeck/Views/SettingsView.swift b/Sources/SkillDeck/Views/SettingsView.swift index 2fa050b..8dcdc32 100644 --- a/Sources/SkillDeck/Views/SettingsView.swift +++ b/Sources/SkillDeck/Views/SettingsView.swift @@ -69,20 +69,38 @@ struct GeneralSettingsView: View { /// State for OpenClaw custom skills directory path (for Docker/volume mount scenarios) @State private var openClawCustomPath: String = "" @State private var isUsingCustomOpenClawPath: Bool = false + @State private var globalSkillsPath: String = "" var body: some View { Form { Section { LabeledContent { - Text(Constants.sharedSkillsPath) - .textSelection(.enabled) // Allow users to select and copy - .foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(globalSkillsPath) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + + Button("浏览...") { + selectGlobalSkillsDirectory() + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button("恢复默认") { + SkillStorageSettings.setGlobalSkillsPath(nil) + globalSkillsPath = SkillStorageSettings.globalSkillsPath + Task { await skillManager.reloadConfiguredStorage() } + } + .buttonStyle(.bordered) + .controlSize(.small) + } } label: { LText(key: L10nKeys.settingsSectionPathsSharedSkills) } LabeledContent { - Text(Constants.lockFilePath) + Text(SkillStorageSettings.lockFileURL.tildeAbbreviatedPath) .textSelection(.enabled) .foregroundStyle(.secondary) } label: { @@ -187,6 +205,7 @@ struct GeneralSettingsView: View { .padding() .onAppear { loadOpenClawCustomPath() + globalSkillsPath = SkillStorageSettings.globalSkillsPath } } @@ -223,7 +242,26 @@ struct GeneralSettingsView: View { openClawCustomPath = url.path AgentPathSettings.setCustomPath(url.path, for: .openClaw) // Trigger refresh to update skills from newly selected path - Task { await skillManager.refresh() } + Task { await skillManager.reloadConfiguredStorage() } + } + } + + /// NSOpenPanel supplies a user-chosen directory while keeping the preference as an absolute path. + /// This avoids relying on shell expansion at every filesystem call. + private func selectGlobalSkillsDirectory() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.showsHiddenFiles = true + panel.message = "选择全局技能目录" + panel.prompt = "选择" + panel.directoryURL = SkillStorageSettings.globalSkillsURL + + if panel.runModal() == .OK, let url = panel.url { + SkillStorageSettings.setGlobalSkillsPath(url.path) + globalSkillsPath = url.path + Task { await skillManager.reloadConfiguredStorage() } } } } diff --git a/Sources/SkillDeck/Views/Sidebar/SidebarView.swift b/Sources/SkillDeck/Views/Sidebar/SidebarView.swift index b2400d4..a478af6 100644 --- a/Sources/SkillDeck/Views/Sidebar/SidebarView.swift +++ b/Sources/SkillDeck/Views/Sidebar/SidebarView.swift @@ -6,6 +6,10 @@ import SwiftUI /// F09 adds `.registry` for browsing the skills.sh catalog. enum SidebarItem: Hashable { case dashboard + /// Whole-directory synchronization for ~/.agents/skills. + case globalSync + /// Explicitly managed project roots. Project skills stay separate from global skills. + case projects /// F09: Browse skills.sh catalog (leaderboard + search) case registry /// Browse the ClawHub marketplace for OpenClaw-compatible skills @@ -21,7 +25,7 @@ enum SidebarItem: Hashable { switch self { case .agent(let agentType): return agentType - case .dashboard, .settings, .registry, .clawHub: + case .dashboard, .globalSync, .projects, .settings, .registry, .clawHub: return nil } } @@ -93,6 +97,28 @@ struct SidebarView: View { .fill(rowBackground(for: .dashboard)) ) + sidebarRow( + item: .globalSync, + accessibilityLabel: "全局同步" + ) { + Label("全局同步", systemImage: "link") + } + .listRowBackground( + RoundedRectangle(cornerRadius: 6) + .fill(rowBackground(for: .globalSync)) + ) + + sidebarRow( + item: .projects, + accessibilityLabel: "项目" + ) { + Label("项目", systemImage: "folder") + } + .listRowBackground( + RoundedRectangle(cornerRadius: 6) + .fill(rowBackground(for: .projects)) + ) + // F09: Registry browser — browse and search skills.sh catalog sidebarRow( item: .registry, diff --git a/Tests/SkillDeckTests/AgentTypeTests.swift b/Tests/SkillDeckTests/AgentTypeTests.swift index 65c6d7b..18553ef 100644 --- a/Tests/SkillDeckTests/AgentTypeTests.swift +++ b/Tests/SkillDeckTests/AgentTypeTests.swift @@ -212,7 +212,7 @@ final class AgentTypeTests: XCTestCase { /// Verify the total number of supported agents /// This test catches accidental removal of agent cases func testAllCasesCount() { - // 14 agents: claudeCode, codex, geminiCLI, copilotCLI, openCode, antigravity, cursor, kiro, codeBuddy, openClaw, trae, qoder, qclaw, workbuddy - XCTAssertEqual(AgentType.allCases.count, 14) + // 17 agents: the original 14 plus JoyCode, Reasonix and Qwen. + XCTAssertEqual(AgentType.allCases.count, 17) } } diff --git a/Tests/SkillDeckTests/GlobalSkillStorageTests.swift b/Tests/SkillDeckTests/GlobalSkillStorageTests.swift new file mode 100644 index 0000000..714daea --- /dev/null +++ b/Tests/SkillDeckTests/GlobalSkillStorageTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import SkillDeck + +/// Tests the configured canonical storage contract used by scanning, installation and global sync. +final class GlobalSkillStorageTests: XCTestCase { + func testConfiguredDirectoryDrivesCanonicalFiles() { + let originalPath = SkillStorageSettings.globalSkillsPath + let configuredURL = FileManager.default.temporaryDirectory.appendingPathComponent("SkillDeckConfiguredSkills-\(UUID().uuidString)") + defer { SkillStorageSettings.setGlobalSkillsPath(originalPath) } + + SkillStorageSettings.setGlobalSkillsPath(configuredURL.path) + + XCTAssertEqual(SkillStorageSettings.globalSkillsURL.path, configuredURL.path) + XCTAssertEqual(SkillScanner.sharedSkillsURL.path, configuredURL.path) + XCTAssertEqual(LockFileManager.defaultPath.path, configuredURL.deletingLastPathComponent().appendingPathComponent(".skill-lock.json").path) + XCTAssertEqual(CommitHashCache.defaultPath.path, configuredURL.deletingLastPathComponent().appendingPathComponent(".skilldeck-cache.json").path) + } + + func testGlobalManagedProjectUsesConfiguredDirectory() { + let originalPath = SkillStorageSettings.globalSkillsPath + let configuredURL = FileManager.default.temporaryDirectory.appendingPathComponent("SkillDeckConfiguredGlobal-\(UUID().uuidString)") + defer { SkillStorageSettings.setGlobalSkillsPath(originalPath) } + + SkillStorageSettings.setGlobalSkillsPath(configuredURL.path) + let globalProject = ManagedProject(rootPath: NSHomeDirectory(), displayName: "全局技能", usesConfiguredGlobalSource: true) + + XCTAssertEqual(globalProject.sourceSkillsURL.path, configuredURL.path) + } + + func testGlobalSyncTargetsMatchSidebarAgents() { + XCTAssertEqual(ProjectAgent.global.map(\.id), AgentType.allCases.map(\.id)) + XCTAssertEqual(ProjectAgent.global.map(\.name), AgentType.allCases.map(\.displayName)) + } +} diff --git a/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift b/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift new file mode 100644 index 0000000..747bc41 --- /dev/null +++ b/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import SkillDeck + +/// Verifies that project update metadata stays under the project rather than the global skill store. +final class ProjectSkillUpdateServiceTests: XCTestCase { + private var projectRoot: URL! + private var sourceRoot: URL! + private var skillDirectory: URL! + + override func setUpWithError() throws { + projectRoot = FileManager.default.temporaryDirectory.appendingPathComponent("SkillDeckProjectUpdate-\(UUID().uuidString)") + sourceRoot = projectRoot.appendingPathComponent(".agents/skills") + skillDirectory = sourceRoot.appendingPathComponent("demo") + try FileManager.default.createDirectory(at: skillDirectory, withIntermediateDirectories: true) + try "---\nname: Demo\ndescription: Project test\n---\n\n# Demo\n".write( + to: skillDirectory.appendingPathComponent("SKILL.md"), + atomically: true, + encoding: .utf8 + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: projectRoot) + } + + func testLoadsRepositoryMetadataFromProjectLockFile() async throws { + let projectLockURL = projectRoot.appendingPathComponent(".agents/.skill-lock.json") + let lockManager = LockFileManager(filePath: projectLockURL) + try await lockManager.createIfNotExists() + let expectedEntry = LockEntry( + source: "owner/repository", + sourceType: "github", + sourceUrl: "https://github.com/owner/repository.git", + skillPath: "skills/demo/SKILL.md", + skillFolderHash: "project-tree-hash", + installedAt: "2026-07-14T00:00:00Z", + updatedAt: "2026-07-14T00:00:00Z" + ) + try await lockManager.updateEntry(skillName: "demo", entry: expectedEntry) + + let service = ProjectSkillUpdateService(sourceRoot: sourceRoot) + let skill = try await service.loadSkill(named: "demo", at: skillDirectory) + + XCTAssertEqual(skill.canonicalURL, skillDirectory) + guard case .project(let scopedProjectURL) = skill.scope else { + return XCTFail("Expected project skill scope") + } + XCTAssertEqual(scopedProjectURL.standardizedFileURL.path, projectRoot.standardizedFileURL.path) + XCTAssertEqual(skill.lockEntry, expectedEntry) + XCTAssertTrue(FileManager.default.fileExists(atPath: projectLockURL.path)) + } +} diff --git a/Tests/SkillDeckTests/ProjectSyncFileSystemTests.swift b/Tests/SkillDeckTests/ProjectSyncFileSystemTests.swift new file mode 100644 index 0000000..c68ec38 --- /dev/null +++ b/Tests/SkillDeckTests/ProjectSyncFileSystemTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import SkillDeck + +/// Project sync tests cover whole-directory migration only; partial per-skill synchronization is unsupported. +final class ProjectSyncFileSystemTests: XCTestCase { + private var root: URL! + private var project: ManagedProject! + private var filesystem: ProjectSyncFileSystem! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("SkillDeckProjectSync-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + project = ManagedProject(rootPath: root.path) + filesystem = ProjectSyncFileSystem(backupRoot: root.appendingPathComponent("backups")) + try makeSkill(named: "demo") + try makeSkill(named: "other") + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + func testCreatesWholeDirectoryLinkWhenTargetIsMissing() throws { + let codex = ProjectAgent.all.first { $0.id == "codex" }! + let target = root.appendingPathComponent(".codex/skills") + + let inspection = filesystem.inspect(project: project, agent: codex) + let change = try XCTUnwrap(filesystem.syncPlan(for: inspection).first) + XCTAssertEqual(change.kind, .createDirectoryLink) + XCTAssertEqual(Set(change.skillNames), ["demo", "other"]) + _ = try filesystem.apply(change) + + XCTAssertTrue(SymlinkManager.isSymlink(at: target)) + XCTAssertEqual(SymlinkManager.resolveSymlink(at: target).standardizedFileURL.path, project.sourceSkillsURL.standardizedFileURL.path) + } + + func testAlreadyManagedDirectoryLinkNeedsNoSync() throws { + let claude = ProjectAgent.all.first { $0.id == "claude-code" }! + let target = root.appendingPathComponent(".claude/skills") + try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: target, withDestinationURL: project.sourceSkillsURL) + + let inspection = filesystem.inspect(project: project, agent: claude) + XCTAssertTrue(filesystem.syncPlan(for: inspection).isEmpty) + } + + func testReplacesExistingDirectoryAfterBackup() throws { + let claude = ProjectAgent.all.first { $0.id == "claude-code" }! + let target = root.appendingPathComponent(".claude/skills") + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try "legacy".write(to: target.appendingPathComponent("note.txt"), atomically: true, encoding: .utf8) + + let inspection = filesystem.inspect(project: project, agent: claude) + var change = try XCTUnwrap(filesystem.syncPlan(for: inspection).first) + XCTAssertEqual(change.kind, .replaceDirectory) + XCTAssertEqual(try filesystem.apply(change), "保留目标 skills 目录") + XCTAssertFalse(SymlinkManager.isSymlink(at: target)) + + change.resolution = .backupAndReplace + _ = try filesystem.apply(change) + XCTAssertTrue(SymlinkManager.isSymlink(at: target)) + XCTAssertEqual(SymlinkManager.resolveSymlink(at: target).standardizedFileURL.path, project.sourceSkillsURL.standardizedFileURL.path) + XCTAssertTrue(FileManager.default.fileExists(atPath: root.appendingPathComponent("backups").path)) + } + + func testReplacesForeignDirectoryLinkAfterBackup() throws { + let claude = ProjectAgent.all.first { $0.id == "claude-code" }! + let oldRoot = root.appendingPathComponent(".ai-global/skills") + try FileManager.default.createDirectory(at: oldRoot, withIntermediateDirectories: true) + let target = root.appendingPathComponent(".claude/skills") + try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: target, withDestinationURL: oldRoot) + + let inspection = filesystem.inspect(project: project, agent: claude) + var change = try XCTUnwrap(filesystem.syncPlan(for: inspection).first) + XCTAssertEqual(change.kind, .replaceDirectory) + change.resolution = .backupAndReplace + _ = try filesystem.apply(change) + + XCTAssertEqual(SymlinkManager.resolveSymlink(at: target).standardizedFileURL.path, project.sourceSkillsURL.standardizedFileURL.path) + } + + func testRemovePlanOnlyRemovesManagedDirectoryLink() throws { + let gemini = ProjectAgent.all.first { $0.id == "gemini-cli" }! + let target = root.appendingPathComponent(".gemini/skills") + try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: target, withDestinationURL: project.sourceSkillsURL) + + let inspection = filesystem.inspect(project: project, agent: gemini) + let change = try XCTUnwrap(filesystem.removePlan(for: inspection).first) + XCTAssertEqual(change.kind, .removeDirectoryLink) + _ = try filesystem.apply(change) + XCTAssertFalse(FileManager.default.fileExists(atPath: target.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: project.sourceSkillsURL.path)) + } + + func testSyncAllTargetsBuildsAndAppliesOneDirectoryPlanPerTool() throws { + let codex = try XCTUnwrap(ProjectAgent.all.first { $0.id == "codex" }) + let cursor = try XCTUnwrap(ProjectAgent.all.first { $0.id == "cursor" }) + let plans = [codex, cursor].flatMap { agent in + filesystem.syncPlan(for: filesystem.inspect(project: project, agent: agent)) + } + + XCTAssertEqual(plans.count, 2) + for plan in plans { + XCTAssertEqual(plan.kind, .createDirectoryLink) + _ = try filesystem.apply(plan) + } + + for agent in [codex, cursor] { + let target = agent.skillsURL(in: project) + XCTAssertTrue(SymlinkManager.isSymlink(at: target)) + XCTAssertEqual(SymlinkManager.resolveSymlink(at: target).standardizedFileURL.path, project.sourceSkillsURL.standardizedFileURL.path) + } + } + + private func makeSkill(named name: String) throws { + let skill = project.sourceSkillsURL.appendingPathComponent(name) + try FileManager.default.createDirectory(at: skill, withIntermediateDirectories: true) + try "---\nname: \(name)\ndescription: test\n---\n".write(to: skill.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + } +} diff --git a/Tests/SkillDeckTests/SkillManagerToggleTests.swift b/Tests/SkillDeckTests/SkillManagerToggleTests.swift deleted file mode 100644 index 1c8a8d6..0000000 --- a/Tests/SkillDeckTests/SkillManagerToggleTests.swift +++ /dev/null @@ -1,293 +0,0 @@ -import XCTest -@testable import SkillDeck - -/// SkillManager toggleAssignment 功能的单元测试 -/// -/// 测试目标:验证开启/关闭 Agent toggle 时: -/// 1. 能够正确创建/删除软连接 -/// 2. refresh() 后 skill.installations 正确更新 -/// 3. 继承安装不能被 toggle -/// -/// 注意:这些测试直接操作真实文件系统路径(~/.claude/skills/ 等), -/// 使用唯一命名的 skill 避免与现有 skill 冲突,并在测试后清理。 -final class SkillManagerToggleTests: XCTestCase { - - /// 唯一的测试 skill 名称,使用 UUID 避免冲突 - var uniqueSkillName: String! - var tempSkillDir: URL! - - override func setUp() async throws { - uniqueSkillName = "test-skill-\(UUID().uuidString.prefix(8))" - - // 在临时目录创建测试 skill - tempSkillDir = FileManager.default.temporaryDirectory - .appendingPathComponent("SkillDeckToggleTests-\(UUID().uuidString)") - .appendingPathComponent(uniqueSkillName) - - try FileManager.default.createDirectory(at: tempSkillDir, withIntermediateDirectories: true) - - // 创建 SKILL.md - let skillMDContent = """ - --- - name: Test Skill - description: A test skill for unit testing - --- - - # Test Skill - This is a test skill. - """ - let skillMDURL = tempSkillDir.appendingPathComponent("SKILL.md") - try skillMDContent.write(to: skillMDURL, atomically: true, encoding: .utf8) - - // 清理可能存在的测试软连接(来自之前失败的测试) - try? await cleanupTestSymlinks() - } - - override func tearDown() async throws { - // 清理测试创建的软连接 - try? await cleanupTestSymlinks() - - // 清理临时目录 - if let tempDir = tempSkillDir?.deletingLastPathComponent() { - try? FileManager.default.removeItem(at: tempDir) - } - } - - /// 清理所有 Agent 目录中可能存在的测试软连接 - private func cleanupTestSymlinks() async throws { - for agentType in AgentType.allCases { - let symlinkPath = agentType.skillsDirectoryURL.appendingPathComponent(uniqueSkillName) - if FileManager.default.fileExists(atPath: symlinkPath.path) { - try FileManager.default.removeItem(at: symlinkPath) - } - } - } - - // MARK: - Helper Methods - - /// 创建模拟的 Skill 模型 - private func createMockSkill() -> Skill { - Skill( - id: uniqueSkillName, - canonicalURL: tempSkillDir, - metadata: SkillMetadata( - name: "Test Skill", - description: "A test skill" - ), - markdownBody: "# Test Skill", - scope: .sharedGlobal, - installations: [] - ) - } - - // MARK: - Toggle Assignment Tests - - /// 测试 toggleAssignment 创建软连接 - /// - /// 场景:Agent 没有安装 skill,toggle 应该创建软连接 - func testToggleAssignmentCreatesSymlink() async throws { - // Given - let skill = createMockSkill() - let expectedSymlinkPath = AgentType.claudeCode.skillsDirectoryURL.appendingPathComponent(uniqueSkillName) - - // When: 创建软连接 - try SymlinkManager.createSymlink(from: skill.canonicalURL, to: .claudeCode) - - // Then: 验证软连接存在 - XCTAssertTrue(FileManager.default.fileExists(atPath: expectedSymlinkPath.path), - "Symlink should exist at \(expectedSymlinkPath.path)") - XCTAssertTrue(SymlinkManager.isSymlink(at: expectedSymlinkPath), - "Path should be a symlink") - - // 验证软连接指向正确的 canonical 路径 - let resolvedPath = SymlinkManager.resolveSymlink(at: expectedSymlinkPath) - XCTAssertEqual(resolvedPath.standardized.path, skill.canonicalURL.standardized.path, - "Symlink should resolve to canonical path") - } - - /// 测试 toggleAssignment 删除软连接 - /// - /// 场景:Agent 已安装 skill(有软连接),toggle 应该删除软连接 - func testToggleAssignmentRemovesSymlink() async throws { - // Given: 先创建软连接 - let skill = createMockSkill() - let symlinkPath = AgentType.claudeCode.skillsDirectoryURL.appendingPathComponent(uniqueSkillName) - - try SymlinkManager.createSymlink(from: skill.canonicalURL, to: .claudeCode) - XCTAssertTrue(FileManager.default.fileExists(atPath: symlinkPath.path), - "Precondition: Symlink should exist before removal") - - // When: 删除软连接 - try SymlinkManager.removeSymlink(skillName: skill.id, from: .claudeCode) - - // Then: 验证软连接已删除 - XCTAssertFalse(FileManager.default.fileExists(atPath: symlinkPath.path), - "Symlink should be removed") - } - - /// 测试 findInstallations 能够正确检测到直接安装 - /// - /// 场景:Agent 目录中有指向 canonical 的软连接 - func testFindInstallationsDetectsDirectInstallation() async throws { - // Given - let skill = createMockSkill() - - // 创建软连接 - try SymlinkManager.createSymlink(from: skill.canonicalURL, to: .claudeCode) - - // When: 查找所有安装 - let installations = SymlinkManager.findInstallations( - skillName: skill.id, - canonicalURL: skill.canonicalURL - ) - - // Then: 验证找到了 Claude Code 的安装 - let claudeInstallation = installations.first { $0.agentType == .claudeCode } - XCTAssertNotNil(claudeInstallation, "Should find Claude Code installation") - XCTAssertTrue(claudeInstallation?.isSymlink ?? false, "Should be a symlink") - XCTAssertFalse(claudeInstallation?.isInherited ?? true, "Should not be inherited") - } - - /// 测试 findInstallations 能够正确检测到继承安装 - /// - /// 场景:Agent A 有 skill,Agent B 可以从 Agent A 的目录读取 - /// 例如:Copilot CLI 可以从 ~/.claude/skills/ 读取 - func testFindInstallationsDetectsInheritedInstallation() async throws { - // Given: 只在 Claude Code 目录创建软连接 - let skill = createMockSkill() - - // 创建软连接到 Claude Code - try SymlinkManager.createSymlink(from: skill.canonicalURL, to: .claudeCode) - - // When: 查找所有安装 - let installations = SymlinkManager.findInstallations( - skillName: skill.id, - canonicalURL: skill.canonicalURL - ) - - // Then: - // 1. Claude Code 应该有直接安装 - let claudeInstallation = installations.first { $0.agentType == .claudeCode } - XCTAssertNotNil(claudeInstallation, "Should find Claude Code direct installation") - XCTAssertFalse(claudeInstallation?.isInherited ?? true, - "Claude Code installation should not be inherited") - - // 2. Copilot CLI 应该有继承安装(因为 Copilot CLI 可以读取 Claude 的目录) - let copilotInstallation = installations.first { $0.agentType == .copilotCLI } - XCTAssertNotNil(copilotInstallation, "Should find Copilot CLI inherited installation") - XCTAssertTrue(copilotInstallation?.isInherited ?? false, - "Copilot CLI installation should be inherited") - XCTAssertEqual(copilotInstallation?.inheritedFrom, .claudeCode, - "Should inherit from Claude Code") - } - - /// 测试删除直接安装后,继承安装也消失 - /// - /// 场景:Agent A 有直接安装,Agent B 继承自 A - /// 当删除 A 的安装后,B 的继承安装也应该消失 - func testRemoveDirectInstallationRemovesInheritedToo() async throws { - // Given - let skill = createMockSkill() - - // 创建 Claude Code 的直接安装 - try SymlinkManager.createSymlink(from: skill.canonicalURL, to: .claudeCode) - - // 验证初始状态:至少有 Claude(直接)+ Copilot(继承) - let installationsBefore = SymlinkManager.findInstallations( - skillName: skill.id, - canonicalURL: skill.canonicalURL - ) - let claudeBefore = installationsBefore.first { $0.agentType == .claudeCode } - let copilotBefore = installationsBefore.first { $0.agentType == .copilotCLI } - XCTAssertNotNil(claudeBefore, "Claude should have direct installation") - XCTAssertFalse(claudeBefore?.isInherited ?? true, "Claude installation should not be inherited") - XCTAssertNotNil(copilotBefore, "Copilot should have inherited installation") - XCTAssertTrue(copilotBefore?.isInherited ?? false, "Copilot installation should be inherited") - - // When: 删除 Claude Code 的直接安装 - try SymlinkManager.removeSymlink(skillName: skill.id, from: .claudeCode) - - // Then: Claude 和 Copilot 的安装都应该消失 - let installationsAfter = SymlinkManager.findInstallations( - skillName: skill.id, - canonicalURL: skill.canonicalURL - ) - let claudeAfter = installationsAfter.first { $0.agentType == .claudeCode } - let copilotAfter = installationsAfter.first { $0.agentType == .copilotCLI } - XCTAssertNil(claudeAfter, "Claude installation should be removed") - XCTAssertNil(copilotAfter, "Copilot inherited installation should also be removed") - } - - /// 测试 Codex 的 ~/.agents/skills/ 不被视为继承安装 - /// - /// 场景:Codex 原生支持 ~/.agents/skills/,虽然 findInstallations 返回 isInherited=true, - /// 但 inheritedFrom=.codex,在 UI 层和 SkillManager 层会被视为非继承 - func testCodexAgentsSkillsIsNotInherited() async throws { - // Given: 在 ~/.agents/skills/ 创建 skill(模拟 Codex 原生读取) - let agentsSkillsDir = AgentType.sharedSkillsDirectoryURL - let skillDir = agentsSkillsDir.appendingPathComponent(uniqueSkillName) - - // 确保目录存在 - try? FileManager.default.createDirectory(at: agentsSkillsDir, withIntermediateDirectories: true) - - // 复制测试 skill 到 ~/.agents/skills/ - try? FileManager.default.copyItem(at: tempSkillDir, to: skillDir) - - // 清理在 tearDown 中进行 - defer { - try? FileManager.default.removeItem(at: skillDir) - } - - // When: 查找安装 - let installations = SymlinkManager.findInstallations( - skillName: uniqueSkillName, - canonicalURL: skillDir - ) - - // Then: 验证 installations 可以正常获取(没有 crash) - XCTAssertNotNil(installations) - - // 验证 Codex 的 installation 的 inheritedFrom 是 .codex - // 这允许 UI 层和 SkillManager 将其视为非继承安装 - let codexInstallation = installations.first { $0.agentType == .codex } - XCTAssertNotNil(codexInstallation, "Should find Codex installation") - XCTAssertEqual(codexInstallation?.inheritedFrom, .codex, - "Codex installation from ~/.agents/skills/ should have inheritedFrom=.codex") - - // 注意:findInstallations 返回的 isInherited 为 true,但 UI/SkillManager 会特殊处理 inheritedFrom=.codex 的情况 - // 这是 isTrulyInherited 逻辑的一部分 - } - - // MARK: - Edge Cases - - /// 测试删除不存在的软连接不会抛出错误 - /// - /// 场景:软连接已被手动删除,再次调用 removeSymlink 应该静默处理 - func testRemoveNonExistentSymlinkDoesNotThrow() async throws { - // Given - let skill = createMockSkill() - - // When/Then: 删除不存在的软连接不应该抛出错误 - XCTAssertNoThrow(try SymlinkManager.removeSymlink(skillName: skill.id, from: .claudeCode)) - } - - /// 测试创建软连接时目标已存在(重复 toggle) - /// - /// 场景:软连接已存在,再次创建应该抛出错误 - func testCreateSymlinkWhenTargetExistsThrows() async throws { - // Given - let skill = createMockSkill() - - // 先创建一次 - try SymlinkManager.createSymlink(from: skill.canonicalURL, to: .claudeCode) - - // When/Then: 再次创建应该抛出错误 - XCTAssertThrowsError(try SymlinkManager.createSymlink(from: skill.canonicalURL, to: .claudeCode)) { error in - guard let symlinkError = error as? SymlinkManager.SymlinkError else { - XCTFail("Expected SymlinkError") - return - } - XCTAssertEqual(symlinkError.errorDescription?.contains("already exists"), true) - } - } -} diff --git a/docs/PROJECT_MANAGEMENT.md b/docs/PROJECT_MANAGEMENT.md new file mode 100644 index 0000000..29b6124 --- /dev/null +++ b/docs/PROJECT_MANAGEMENT.md @@ -0,0 +1,107 @@ +# 项目技能管理 + +## 2026-07-14 当前实现 + +SkillDeck 的全局技能目录由“设置 > 全局技能目录”配置,默认值是 `~/.agents/skills`。全局同步与安装、扫描、锁文件、缓存都读取该配置;全局同步的目标工具严格复用左侧“代理”栏的 `AgentType` 列表和工具配置路径。项目管理功能新增了一个独立的“项目”侧栏入口,不会把项目技能混入全局仪表盘,也不会递归扫描磁盘。 + +用户通过“项目”列表工具栏手动选择项目根目录。根目录会保存到 `UserDefaults` 的 `SkillDeck.managedProjects`;应用重启后仍会显示存在的目录。移除项目只移除 SkillDeck 的记录,不会删除项目文件。 + +## 目录与范围 + +每个项目以以下目录作为唯一源: + +```text +/.agents/skills +``` + +只读取其中包含 `SKILL.md` 的一级技能目录。支持审计和同步的项目目标为: + +```text +.claude/skills +.codex/skills +.gemini/skills +.cursor/skills +.qoder/skills +.trae/skills +.kiro/skills +.joycode/skills +.reasonix/skills +.qwen/skills +.copilot/skills +.config/opencode/skills +.gemini/antigravity/skills +.codebuddy/skills +.openclaw/skills +.qclaw/skills +.workbuddy/skills +``` + +### 2026-07-14 已知工具目录创建 + +应用会为上表中已明确支持的工具创建缺失的项目配置目录和 `skills` 软链,例如 `.codex/skills`。不会对上表之外的未知工具路径写入。项目选择和扫描只处理显式添加的根目录,不会递归发现其它仓库。 + +## 界面工作流 + +1. 左侧选择“项目”。 +2. 中间栏列出所有已添加项目,显示源技能数和已完全同步的目标工具数。 +3. 选择项目后,右侧顶部的工具状态条显示每个目标工具是否已整体同步;点击任意工具查看项目源技能清单。 +4. 点击“同步全部工具”可一次同步该源的所有 AI 工具;也可点击“同步整个 skills 目录”只同步当前工具。预览弹窗是唯一可写入文件系统的确认入口。 + +项目页保持与全局仪表盘相同的三栏导航结构:左侧导航、中间对象列表、右侧详情,而不是通过下拉框切换项目。 + +## 同步规则 + +### 2026-07-14 整目录迁移 + +项目同步始终迁移完整的 `.agents/skills` 目录;全局同步迁移“设置”中配置的全局源目录,目标使用整目录软链: + +```text +/.claude/skills -> /.agents/skills +``` + +目标不存在时创建该目录软链。目标已经是其它软链或真实目录时,预览会显示“备份后整体替换”;确认后会备份整个目标 `skills` 目录,再替换为指向项目 `.agents/skills` 的软链。项目页不支持单技能选择或部分同步。 + +### 已有内容与备份 + +目标是其它软链、真实目录或文件时默认“跳过”。选择“备份后替换”后,原目标项目会移动到: + +```text +~/Library/Application Support/SkillDeck/ProjectSyncBackups// +``` + +随后才创建新的 `.agents` 软链。移除操作只删除由当前同步关系管理的软链,不会删除源技能。 + +## 关键实现位置 + +- `Sources/SkillDeck/ViewModels/ProjectManager.swift` + - `ProjectSyncFileSystem`:扫描、变更规划、备份、软链创建与移除。 + - `ProjectManager`:持久化项目根目录、选中状态、变更预览和执行日志。 +- `Sources/SkillDeck/Views/Projects/ProjectManagementView.swift` + - 项目列表、中间栏项目行、右侧工具状态条、技能明细和变更确认弹窗。 +- `Sources/SkillDeck/Views/ContentView.swift` + - 将项目管理加入现有三栏导航。 +- `Sources/SkillDeck/Views/Sidebar/SidebarView.swift` + - 新增“项目”导航入口。 +- `Tests/SkillDeckTests/ProjectSyncFileSystemTests.swift` + - 覆盖整目录链接创建、已同步识别、真实目录/外部软链的整体备份替换,以及受管理目录链接移除。 + +## 验证 + +```bash +swift test --filter ProjectSyncFileSystemTests +swift build +bash scripts/package-app.sh --version 0.1.0-local +``` + +本机构建后的应用安装位置:`/Applications/SkillDeck.app`。 + +## 当前边界 + +- 第一版只管理 skills,不处理 `AGENTS.md`、规则、命令或 Agent 配置。 +- 只支持用户手动添加项目根目录。 +- 只支持整目录 `.agents` 软链;不支持部分选择或单技能软链。 +- 应用不会自动将既有 `.ai-global/skills` 等非 `.agents` 目录视为已同步;它们会作为冲突显示,避免静默改变项目配置。 + +### 2026-07-14 项目级更新 + +项目技能详情可关联 GitHub 仓库、检查更新并更新。关联成功会立即同步远端内容;检查完成后会明确显示“发现可用更新”或“当前已是最新版本”,更新完成后会显示成功反馈。项目更新记录写入项目自身的 `/.agents/.skill-lock.json`,更新只替换该项目的技能目录,不会读写全局 `~/.agents` 的锁文件或技能文件。 From 1cab0749126c5d827c9d775add9399594a8aeec8 Mon Sep 17 00:00:00 2001 From: voidcat Date: Tue, 14 Jul 2026 16:55:45 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(rule-sync):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=A7=84=E5=88=99=E4=B8=8E=20Cursor=20?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/RuleSyncFileSystem.swift | 249 ++++++++++++++++++ .../SkillDeck/ViewModels/ProjectManager.swift | 77 ++++++ .../Projects/ProjectManagementView.swift | 131 +++++++++ .../RuleSyncFileSystemTests.swift | 118 +++++++++ docs/PROJECT_MANAGEMENT.md | 30 ++- 5 files changed, 603 insertions(+), 2 deletions(-) create mode 100644 Sources/SkillDeck/Services/RuleSyncFileSystem.swift create mode 100644 Tests/SkillDeckTests/RuleSyncFileSystemTests.swift diff --git a/Sources/SkillDeck/Services/RuleSyncFileSystem.swift b/Sources/SkillDeck/Services/RuleSyncFileSystem.swift new file mode 100644 index 0000000..b361924 --- /dev/null +++ b/Sources/SkillDeck/Services/RuleSyncFileSystem.swift @@ -0,0 +1,249 @@ +import Foundation + +/// Cursor needs a structured `.mdc` rule to appear in its Rules UI. Other supported targets can +/// consume a plain markdown file directly, so a symbolic link remains sufficient for them. +enum RuleTargetFormat: Equatable { + case symbolicLink + case cursorRule +} + +/// RuleSyncTarget describes one documented rules entry point for a supported tool. +/// Project and global paths differ because project rules live beside source code while +/// global rules live in each tool's user configuration directory. +struct RuleSyncTarget: Identifiable, Hashable { + let id: String + let name: String + let projectRelativePath: String + let globalRelativePath: String + let format: RuleTargetFormat + + init( + id: String, + name: String, + projectRelativePath: String, + globalRelativePath: String, + format: RuleTargetFormat = .symbolicLink + ) { + self.id = id + self.name = name + self.projectRelativePath = projectRelativePath + self.globalRelativePath = globalRelativePath + self.format = format + } + + /// Some documented rule locations exist only per repository, such as Copilot's `.github` file. + var supportsGlobalSynchronization: Bool { !globalRelativePath.isEmpty } + + func url(in project: ManagedProject) -> URL { + let relativePath = project.usesConfiguredGlobalSource == true ? globalRelativePath : projectRelativePath + precondition(!relativePath.isEmpty, "This rules target is project-only") + return project.rootURL.appendingPathComponent(relativePath) + } + + static let all: [RuleSyncTarget] = [ + .init(id: "codex", name: "Codex / 通用 Agent", projectRelativePath: "AGENTS.md", globalRelativePath: ".codex/AGENTS.md"), + .init(id: "claude", name: "Claude Code", projectRelativePath: "CLAUDE.md", globalRelativePath: ".claude/CLAUDE.md"), + .init(id: "gemini", name: "Gemini CLI", projectRelativePath: "GEMINI.md", globalRelativePath: ".gemini/GEMINI.md"), + .init(id: "claude-rules", name: "Claude 规则目录", projectRelativePath: ".claude/rules/skilldeck.md", globalRelativePath: ".claude/rules/skilldeck.md"), + .init(id: "copilot", name: "GitHub Copilot", projectRelativePath: ".github/copilot-instructions.md", globalRelativePath: ""), + .init(id: "kiro", name: "Kiro Steering", projectRelativePath: ".kiro/steering/skilldeck.md", globalRelativePath: ".kiro/steering/skilldeck.md"), + .init(id: "cursor", name: "Cursor 项目规则", projectRelativePath: ".cursor/rules/skilldeck.mdc", globalRelativePath: "", format: .cursorRule) + ] +} + +enum RuleLinkState: String { + case linked, missing, broken, foreignLink, occupied + + var label: String { + switch self { + case .linked: "已同步" + case .missing: "未同步" + case .broken: "失效软链" + case .foreignLink: "指向其他位置" + case .occupied: "已有规则文件" + } + } +} + +struct RuleInspection: Identifiable { + let project: ManagedProject + let target: RuleSyncTarget + let sourceURL: URL + let targetURL: URL + let state: RuleLinkState + + var id: String { project.id + "|" + target.id } +} + +enum RuleChangeKind: String { + case createLink, replaceItem, removeLink + + var label: String { + switch self { + case .createLink: "创建规则软链" + case .replaceItem: "备份后替换规则" + case .removeLink: "移除规则软链" + } + } +} + +struct RuleChange: Identifiable { + let id = UUID() + let kind: RuleChangeKind + let target: RuleSyncTarget + let sourceURL: URL + let targetURL: URL + let summary: String + let needsResolution: Bool + var resolution: ProjectConflictResolution = .skip +} + +/// RuleSyncFileSystem is kept separate from ProjectSyncFileSystem because rules are individual +/// files, while skills are one complete directory link. This keeps their backup and removal rules +/// unambiguous and makes both workflows directly testable without SwiftUI. +struct RuleSyncFileSystem { + let fileManager: FileManager + let backupRoot: URL + let targets: [RuleSyncTarget] + + init(fileManager: FileManager = .default, backupRoot: URL? = nil, targets: [RuleSyncTarget] = RuleSyncTarget.all) { + self.fileManager = fileManager + self.backupRoot = backupRoot ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("SkillDeck/RuleSyncBackups", isDirectory: true) + self.targets = targets + } + + func sourceURL(for project: ManagedProject) -> URL { + project.usesConfiguredGlobalSource == true + ? SkillStorageSettings.globalSkillsURL.deletingLastPathComponent().appendingPathComponent("AGENTS.md") + : project.rootURL.appendingPathComponent(".agents/AGENTS.md") + } + + func sourceExists(for project: ManagedProject) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: sourceURL(for: project).path, isDirectory: &isDirectory) && !isDirectory.boolValue + } + + func createSource(for project: ManagedProject) throws { + let source = sourceURL(for: project) + guard !fileManager.fileExists(atPath: source.path) else { return } + try fileManager.createDirectory(at: source.deletingLastPathComponent(), withIntermediateDirectories: true) + try "# Agent Rules\n\n".write(to: source, atomically: true, encoding: .utf8) + } + + func inspect(project: ManagedProject) -> [RuleInspection] { + let source = sourceURL(for: project) + return targets.filter { project.usesConfiguredGlobalSource != true || $0.supportsGlobalSynchronization }.map { target in + let targetURL = target.url(in: project) + let state: RuleLinkState + if target.format == .cursorRule, + let contents = try? String(contentsOf: targetURL, encoding: .utf8), + contents == cursorRuleContents() { + state = .linked + } else if let destination = linkDestination(at: targetURL) { + if sameLocation(destination, source) { + state = .linked + } else if fileManager.fileExists(atPath: targetURL.path) { + state = .foreignLink + } else { + state = .broken + } + } else if fileManager.fileExists(atPath: targetURL.path) { + state = .occupied + } else { + state = .missing + } + return RuleInspection(project: project, target: target, sourceURL: source, targetURL: targetURL, state: state) + } + } + + func syncPlan(for project: ManagedProject) -> [RuleChange] { + guard sourceExists(for: project) else { return [] } + return inspect(project: project).compactMap { inspection in + guard inspection.state != .linked else { return nil } + if fileManager.fileExists(atPath: inspection.targetURL.path) || linkDestination(at: inspection.targetURL) != nil { + return RuleChange( + kind: .replaceItem, + target: inspection.target, + sourceURL: inspection.sourceURL, + targetURL: inspection.targetURL, + summary: "已有规则文件或外部软链,备份后替换为统一规则。", + needsResolution: true + ) + } + return RuleChange( + kind: .createLink, + target: inspection.target, + sourceURL: inspection.sourceURL, + targetURL: inspection.targetURL, + summary: inspection.target.format == .cursorRule + ? "创建 Cursor 规则文件,并引用统一的 AGENTS.md。" + : "创建规则软链,指向统一的 AGENTS.md。", + needsResolution: false + ) + } + } + + func removePlan(for project: ManagedProject) -> [RuleChange] { + inspect(project: project).compactMap { inspection in + guard inspection.state == .linked else { return nil } + return RuleChange( + kind: .removeLink, + target: inspection.target, + sourceURL: inspection.sourceURL, + targetURL: inspection.targetURL, + summary: "只移除由 SkillDeck 创建的规则软链,不删除统一规则源。", + needsResolution: false + ) + } + } + + func apply(_ change: RuleChange) throws -> String { + switch change.kind { + case .createLink: + try fileManager.createDirectory(at: change.targetURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try writeTarget(for: change) + case .replaceItem: + guard change.resolution == .backupAndReplace else { return "保留 \(change.target.name) 的现有规则" } + let backup = try backup(change.targetURL) + try fileManager.createDirectory(at: change.targetURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try writeTarget(for: change) + return "\(change.target.name) 已备份到 \(backup.path)" + case .removeLink: + try fileManager.removeItem(at: change.targetURL) + } + return "已执行 \(change.kind.label):\(change.target.name)" + } + + private func linkDestination(at url: URL) -> URL? { + guard let value = try? fileManager.destinationOfSymbolicLink(atPath: url.path) else { return nil } + return URL(fileURLWithPath: value, relativeTo: url.deletingLastPathComponent()).standardizedFileURL + } + + private func sameLocation(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.resolvingSymlinksInPath().standardizedFileURL.path == rhs.resolvingSymlinksInPath().standardizedFileURL.path + } + + /// Cursor displays only `.mdc` project rules in Customize. The wrapper stays tiny and uses + /// Cursor's documented @file reference syntax, so the editable rule text remains in one source. + private func cursorRuleContents() -> String { + "---\ndescription: Shared SkillDeck agent rules\nalwaysApply: true\n---\n@../../.agents/AGENTS.md\n" + } + + private func writeTarget(for change: RuleChange) throws { + if change.target.format == .cursorRule { + try cursorRuleContents().write(to: change.targetURL, atomically: true, encoding: .utf8) + } else { + try fileManager.createSymbolicLink(at: change.targetURL, withDestinationURL: change.sourceURL) + } + } + + private func backup(_ item: URL) throws -> URL { + let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") + let folder = backupRoot.appendingPathComponent(stamp, isDirectory: true) + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + let destination = folder.appendingPathComponent(item.lastPathComponent + "-" + UUID().uuidString) + try fileManager.moveItem(at: item, to: destination) + return destination + } +} diff --git a/Sources/SkillDeck/ViewModels/ProjectManager.swift b/Sources/SkillDeck/ViewModels/ProjectManager.swift index bce9f39..0ea9e43 100644 --- a/Sources/SkillDeck/ViewModels/ProjectManager.swift +++ b/Sources/SkillDeck/ViewModels/ProjectManager.swift @@ -260,6 +260,7 @@ struct ProjectSyncFileSystem { final class ProjectManager { private static let storageKey = "SkillDeck.managedProjects" private let filesystem = ProjectSyncFileSystem() + private let ruleFileSystem = RuleSyncFileSystem() private let fixedProject: ManagedProject? var projects: [ManagedProject] = [] var inspections: [ProjectInspection] = [] @@ -269,6 +270,11 @@ final class ProjectManager { var showsPreview = false var applyErrorMessage: String? var applyResultMessage: String? + var ruleInspections: [RuleInspection] = [] + var ruleChanges: [RuleChange] = [] + var showsRulePreview = false + var ruleApplyErrorMessage: String? + var ruleApplyResultMessage: String? var logs: [String] = [] /// Project-local targets and global AI tools intentionally use different path resolvers. @@ -303,6 +309,13 @@ final class ProjectManager { inspections.first { $0.project.id == projectID && $0.agent.id == targetID } } + /// Rules are intentionally tracked per selected source, independent from the selected skills target. + var selectedProject: ManagedProject? { projects.first { $0.id == selectedProjectID } } + var selectedRulesSourceURL: URL? { selectedProject.map { ruleFileSystem.sourceURL(for: $0) } } + var hasSelectedRulesSource: Bool { selectedProject.map { ruleFileSystem.sourceExists(for: $0) } ?? false } + var selectedRuleInspections: [RuleInspection] { ruleInspections.filter { $0.project.id == selectedProjectID } } + var linkedRuleCount: Int { selectedRuleInspections.filter { $0.state == .linked }.count } + func addProject(_ url: URL) { guard fixedProject == nil else { return } let project = ManagedProject(rootPath: url.standardizedFileURL.path) @@ -329,6 +342,7 @@ final class ProjectManager { func reload() { inspections = projects.flatMap { project in targets.map { filesystem.inspect(project: project, agent: $0) } } + ruleInspections = projects.flatMap { ruleFileSystem.inspect(project: $0) } log("已扫描 \(projects.count) 个项目") } @@ -356,6 +370,33 @@ final class ProjectManager { } func previewRemoval() { guard let inspection = selectedInspection else { return }; changes = filesystem.removePlan(for: inspection); showsPreview = !changes.isEmpty; log("移除预览 \(changes.count) 项") } + /// Creates only the canonical source file. Target rule files are never generated until the user + /// explicitly opens the rules preview and confirms synchronization. + func createRulesSource() { + guard let project = selectedProject else { return } + do { + try ruleFileSystem.createSource(for: project) + reload() + log("已创建统一规则源 \(ruleFileSystem.sourceURL(for: project).path)") + } catch { + ruleApplyErrorMessage = error.localizedDescription + } + } + + func previewRuleSyncAll() { + guard let project = selectedProject else { return } + ruleChanges = ruleFileSystem.syncPlan(for: project) + showsRulePreview = !ruleChanges.isEmpty + log("规则同步预览 \(ruleChanges.count) 项") + } + + func previewRuleRemoval() { + guard let project = selectedProject else { return } + ruleChanges = ruleFileSystem.removePlan(for: project) + showsRulePreview = !ruleChanges.isEmpty + log("规则移除预览 \(ruleChanges.count) 项") + } + /// Applies the whole-directory migration after its explicit confirmation action. func applyChanges(replacingExistingDirectory: Bool = false) { applyErrorMessage = nil @@ -396,6 +437,42 @@ final class ProjectManager { applyResultMessage = nil } + func applyRuleChanges(replacingExistingRules: Bool = false) { + ruleApplyErrorMessage = nil + ruleApplyResultMessage = nil + var failures: [String] = [] + var results: [String] = [] + + for index in ruleChanges.indices where ruleChanges[index].needsResolution && replacingExistingRules { + ruleChanges[index].resolution = .backupAndReplace + } + + for change in ruleChanges { + do { + let result = try ruleFileSystem.apply(change) + log("规则:\(result)") + results.append(result) + } catch { + let message = error.localizedDescription + log("规则失败:\(message)") + failures.append(message) + } + } + + reload() + if failures.isEmpty { + ruleApplyResultMessage = results.joined(separator: "\n") + } else { + ruleApplyErrorMessage = failures.joined(separator: "\n") + } + } + + func finishApplyingRuleChanges() { + ruleChanges = [] + showsRulePreview = false + ruleApplyResultMessage = nil + } + private func save() { guard fixedProject == nil, let data = try? JSONEncoder().encode(projects) else { return } UserDefaults.standard.set(data, forKey: Self.storageKey) diff --git a/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift b/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift index ab66400..b9bf04a 100644 --- a/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift +++ b/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift @@ -114,6 +114,8 @@ struct ProjectSyncDetail: View { let inspection = manager.selectedInspection { VStack(alignment: .leading, spacing: 0) { projectHeader(project) + rulesSection(project) + Divider() agentRail(project) Divider() skillsContent(inspection) @@ -124,6 +126,9 @@ struct ProjectSyncDetail: View { .sheet(isPresented: $manager.showsPreview) { ProjectChangePreview(manager: manager) } + .sheet(isPresented: $manager.showsRulePreview) { + RuleChangePreview(manager: manager) + } .sheet(item: $selectedSourceSkill) { skill in if manager.isGlobalSync, skillManager.skills.contains(where: { $0.id == skill.name && $0.canonicalURL.standardizedFileURL == skill.sourceURL.standardizedFileURL }) { @@ -151,6 +156,46 @@ struct ProjectSyncDetail: View { .padding() } + /// Rule controls are source-level actions: selecting a single skills target must not change + /// which project rules are created, backed up, or removed. + private func rulesSection(_ project: ManagedProject) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("统一规则", systemImage: "doc.text") + .appFont(.headline) + Spacer() + Text("\(manager.linkedRuleCount)/\(manager.selectedRuleInspections.count) 已同步") + .appFont(.caption) + .foregroundStyle(manager.linkedRuleCount == manager.selectedRuleInspections.count && manager.hasSelectedRulesSource ? .green : .secondary) + } + if let sourceURL = manager.selectedRulesSourceURL { + Text(sourceURL.tildeAbbreviatedPath) + .appFont(.caption) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + HStack(spacing: 8) { + if manager.hasSelectedRulesSource, let sourceURL = manager.selectedRulesSourceURL { + Button { + NSWorkspace.shared.open(sourceURL) + } label: { + Image(systemName: "pencil") + } + .help("编辑统一规则") + + Button("同步全部规则") { manager.previewRuleSyncAll() } + .buttonStyle(.borderedProminent) + Button("移除规则链接", role: .destructive) { manager.previewRuleRemoval() } + .disabled(manager.linkedRuleCount == 0) + } else { + Button("新建统一规则") { manager.createRulesSource() } + .buttonStyle(.borderedProminent) + } + } + } + .padding() + } + private func agentRail(_ project: ManagedProject) -> some View { ScrollView(.horizontal) { HStack(spacing: 6) { @@ -339,3 +384,89 @@ private struct ProjectChangePreview: View { } } } + +/// RuleChangePreview mirrors the skills confirmation dialog but lists files, not directories. +/// Existing rules remain untouched until the single destructive confirmation is selected. +private struct RuleChangePreview: View { + @Bindable var manager: ProjectManager + @Environment(\.dismiss) private var dismiss + + private var primaryActionLabel: String { + if manager.ruleChanges.contains(where: \.needsResolution) { + return "备份后同步 \(manager.ruleChanges.count) 条规则" + } + return manager.ruleChanges.first?.kind == .removeLink + ? "移除 \(manager.ruleChanges.count) 条规则链接" + : "同步 \(manager.ruleChanges.count) 条规则" + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("统一规则变更").appFont(.headline) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("关闭") + } + .padding() + + Divider() + + Text("所有目标规则将引用同一份 AGENTS.md;Cursor 使用可识别的 .mdc 包装规则。") + .appFont(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding([.horizontal, .top]) + + List(manager.ruleChanges) { change in + VStack(alignment: .leading, spacing: 4) { + Text("\(change.kind.label):\(change.target.name)") + .appFont(.headline) + Text(change.targetURL.tildeAbbreviatedPath) + .appFont(.caption) + .foregroundStyle(.secondary) + Text(change.summary) + .appFont(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 3) + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + + Divider() + + HStack { + Button("取消") { dismiss() } + Spacer() + Button(primaryActionLabel) { + manager.applyRuleChanges(replacingExistingRules: manager.ruleChanges.contains(where: \.needsResolution)) + } + .buttonStyle(.borderedProminent) + } + .padding() + } + .frame(minWidth: 560, idealWidth: 680, maxWidth: 820, minHeight: 320, maxHeight: 620) + .alert("规则同步失败", isPresented: Binding( + get: { manager.ruleApplyErrorMessage != nil }, + set: { if !$0 { manager.ruleApplyErrorMessage = nil } } + )) { + Button("好", role: .cancel) { manager.ruleApplyErrorMessage = nil } + } message: { + Text(manager.ruleApplyErrorMessage ?? "") + } + .alert("规则同步结果", isPresented: Binding( + get: { manager.ruleApplyResultMessage != nil }, + set: { if !$0 { manager.ruleApplyResultMessage = nil } } + )) { + Button("完成") { + manager.finishApplyingRuleChanges() + dismiss() + } + } message: { + Text(manager.ruleApplyResultMessage ?? "") + } + } +} diff --git a/Tests/SkillDeckTests/RuleSyncFileSystemTests.swift b/Tests/SkillDeckTests/RuleSyncFileSystemTests.swift new file mode 100644 index 0000000..a6470b9 --- /dev/null +++ b/Tests/SkillDeckTests/RuleSyncFileSystemTests.swift @@ -0,0 +1,118 @@ +import XCTest +@testable import SkillDeck + +/// RuleSyncFileSystemTests exercise rules separately from skills because their targets are files, +/// and a real file must be backed up before a canonical rules symlink replaces it. +final class RuleSyncFileSystemTests: XCTestCase { + private var root: URL! + private var project: ManagedProject! + private var target: RuleSyncTarget! + private var filesystem: RuleSyncFileSystem! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("SkillDeckRuleSync-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + project = ManagedProject(rootPath: root.path) + target = RuleSyncTarget( + id: "test", + name: "Test Agent", + projectRelativePath: ".test/AGENTS.md", + globalRelativePath: ".test-global/AGENTS.md" + ) + filesystem = RuleSyncFileSystem( + backupRoot: root.appendingPathComponent("backups"), + targets: [target] + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + func testMissingSourceDoesNotProduceSynchronizationPlan() { + XCTAssertFalse(filesystem.sourceExists(for: project)) + XCTAssertTrue(filesystem.syncPlan(for: project).isEmpty) + } + + func testCreatesCanonicalSourceAndTargetLink() throws { + try filesystem.createSource(for: project) + let source = filesystem.sourceURL(for: project) + let change = try XCTUnwrap(filesystem.syncPlan(for: project).first) + + XCTAssertEqual(change.kind, .createLink) + _ = try filesystem.apply(change) + + let targetURL = target.url(in: project) + XCTAssertTrue(SymlinkManager.isSymlink(at: targetURL)) + XCTAssertEqual(SymlinkManager.resolveSymlink(at: targetURL).standardizedFileURL.path, source.standardizedFileURL.path) + } + + func testExistingRuleIsBackedUpBeforeReplacement() throws { + try filesystem.createSource(for: project) + let targetURL = target.url(in: project) + try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try "legacy rule".write(to: targetURL, atomically: true, encoding: .utf8) + + var change = try XCTUnwrap(filesystem.syncPlan(for: project).first) + XCTAssertEqual(change.kind, .replaceItem) + XCTAssertEqual(try filesystem.apply(change), "保留 Test Agent 的现有规则") + XCTAssertFalse(SymlinkManager.isSymlink(at: targetURL)) + + change.resolution = .backupAndReplace + _ = try filesystem.apply(change) + XCTAssertTrue(SymlinkManager.isSymlink(at: targetURL)) + XCTAssertTrue(FileManager.default.fileExists(atPath: root.appendingPathComponent("backups").path)) + } + + func testRemovalOnlyIncludesManagedRuleLinks() throws { + try filesystem.createSource(for: project) + let change = try XCTUnwrap(filesystem.syncPlan(for: project).first) + _ = try filesystem.apply(change) + + let removal = try XCTUnwrap(filesystem.removePlan(for: project).first) + XCTAssertEqual(removal.kind, .removeLink) + _ = try filesystem.apply(removal) + + XCTAssertFalse(FileManager.default.fileExists(atPath: target.url(in: project).path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: filesystem.sourceURL(for: project).path)) + } + + func testGlobalInspectionExcludesProjectOnlyTargets() { + let globalProject = ManagedProject(rootPath: root.path, usesConfiguredGlobalSource: true) + let globalTarget = RuleSyncTarget( + id: "global", + name: "Global Agent", + projectRelativePath: "AGENTS.md", + globalRelativePath: ".global/AGENTS.md" + ) + let projectOnlyTarget = RuleSyncTarget( + id: "project-only", + name: "Project Only", + projectRelativePath: ".github/copilot-instructions.md", + globalRelativePath: "" + ) + let scopedFilesystem = RuleSyncFileSystem(targets: [globalTarget, projectOnlyTarget]) + + XCTAssertEqual(scopedFilesystem.inspect(project: globalProject).map(\.target.id), ["global"]) + } + + func testCursorRuleReferencesCanonicalSourceAndAppearsAsManaged() throws { + let cursorTarget = RuleSyncTarget( + id: "cursor", + name: "Cursor", + projectRelativePath: ".cursor/rules/skilldeck.mdc", + globalRelativePath: "", + format: .cursorRule + ) + let cursorFilesystem = RuleSyncFileSystem(targets: [cursorTarget]) + try cursorFilesystem.createSource(for: project) + + let change = try XCTUnwrap(cursorFilesystem.syncPlan(for: project).first) + _ = try cursorFilesystem.apply(change) + + let contents = try String(contentsOf: cursorTarget.url(in: project), encoding: .utf8) + XCTAssertTrue(contents.contains("alwaysApply: true")) + XCTAssertTrue(contents.contains("@../../.agents/AGENTS.md")) + XCTAssertEqual(cursorFilesystem.inspect(project: project).first?.state, .linked) + } +} diff --git a/docs/PROJECT_MANAGEMENT.md b/docs/PROJECT_MANAGEMENT.md index 29b6124..a6b27a1 100644 --- a/docs/PROJECT_MANAGEMENT.md +++ b/docs/PROJECT_MANAGEMENT.md @@ -2,7 +2,7 @@ ## 2026-07-14 当前实现 -SkillDeck 的全局技能目录由“设置 > 全局技能目录”配置,默认值是 `~/.agents/skills`。全局同步与安装、扫描、锁文件、缓存都读取该配置;全局同步的目标工具严格复用左侧“代理”栏的 `AgentType` 列表和工具配置路径。项目管理功能新增了一个独立的“项目”侧栏入口,不会把项目技能混入全局仪表盘,也不会递归扫描磁盘。 +SkillDeck 的全局技能目录由“设置 > 全局技能目录”配置,默认值是 `~/.agents/skills`。全局同步与安装、扫描、锁文件、缓存都读取该配置;全局同步的目标工具严格复用左侧“代理”栏的 `AgentType` 列表和工具配置路径。项目管理功能新增了一个独立的“项目”侧栏入口,不会把项目技能混入全局仪表盘,也不会递归扫描磁盘。项目与全局同步页均可管理统一规则,规则不会混入技能目录同步。 用户通过“项目”列表工具栏手动选择项目根目录。根目录会保存到 `UserDefaults` 的 `SkillDeck.managedProjects`;应用重启后仍会显示存在的目录。移除项目只移除 SkillDeck 的记录,不会删除项目文件。 @@ -14,6 +14,18 @@ SkillDeck 的全局技能目录由“设置 > 全局技能目录”配置,默 /.agents/skills ``` +统一规则的唯一源为: + +```text +/.agents/AGENTS.md +``` + +全局同步使用“设置 > 全局技能目录”的父目录下的 `AGENTS.md`,默认是: + +```text +~/.agents/AGENTS.md +``` + 只读取其中包含 `SKILL.md` 的一级技能目录。支持审计和同步的项目目标为: ```text @@ -46,6 +58,7 @@ SkillDeck 的全局技能目录由“设置 > 全局技能目录”配置,默 2. 中间栏列出所有已添加项目,显示源技能数和已完全同步的目标工具数。 3. 选择项目后,右侧顶部的工具状态条显示每个目标工具是否已整体同步;点击任意工具查看项目源技能清单。 4. 点击“同步全部工具”可一次同步该源的所有 AI 工具;也可点击“同步整个 skills 目录”只同步当前工具。预览弹窗是唯一可写入文件系统的确认入口。 +5. 右侧“统一规则”区域可新建或编辑唯一规则源,并同步全部规则入口。项目会创建 `AGENTS.md`、`CLAUDE.md`、`GEMINI.md`、`.claude/rules/skilldeck.md`、`.github/copilot-instructions.md`、`.kiro/steering/skilldeck.md`,以及会在 Cursor Rules 面板显示的 `.cursor/rules/skilldeck.mdc`;全局会创建 Codex、Claude、Gemini、Claude Rules、Kiro Steering 的对应用户级入口。 项目页保持与全局仪表盘相同的三栏导航结构:左侧导航、中间对象列表、右侧详情,而不是通过下拉框切换项目。 @@ -71,11 +84,23 @@ SkillDeck 的全局技能目录由“设置 > 全局技能目录”配置,默 随后才创建新的 `.agents` 软链。移除操作只删除由当前同步关系管理的软链,不会删除源技能。 +### 2026-07-14 统一规则 + +规则同步始终以一份 `AGENTS.md` 为内容源;大多数目标是规则文件软链,不复制规则内容。Cursor 项目规则会生成 `.cursor/rules/skilldeck.mdc`,其中只包含 `alwaysApply` 元数据和对统一源的 `@../../.agents/AGENTS.md` 引用,因此会出现在 Cursor 的 Rules 列表且不产生第二份规则正文。Cursor User Rules 由 Cursor 内部配置存储,未提供可安全同步的文件入口。源不存在时只能通过“新建统一规则”创建,不会自动用空文件写入目标。已有真实规则文件或外部软链会先在预览中列为冲突,确认后备份到: + +```text +~/Library/Application Support/SkillDeck/RuleSyncBackups// +``` + +移除规则链接只会删除仍指向该统一规则源的软链,不会删除源文件或外部规则。 + ## 关键实现位置 - `Sources/SkillDeck/ViewModels/ProjectManager.swift` - `ProjectSyncFileSystem`:扫描、变更规划、备份、软链创建与移除。 - `ProjectManager`:持久化项目根目录、选中状态、变更预览和执行日志。 +- `Sources/SkillDeck/Services/RuleSyncFileSystem.swift` + - 统一规则源、规则入口扫描、冲突备份、软链创建与移除。 - `Sources/SkillDeck/Views/Projects/ProjectManagementView.swift` - 项目列表、中间栏项目行、右侧工具状态条、技能明细和变更确认弹窗。 - `Sources/SkillDeck/Views/ContentView.swift` @@ -89,6 +114,7 @@ SkillDeck 的全局技能目录由“设置 > 全局技能目录”配置,默 ```bash swift test --filter ProjectSyncFileSystemTests +swift test --filter RuleSyncFileSystemTests swift build bash scripts/package-app.sh --version 0.1.0-local ``` @@ -97,7 +123,7 @@ bash scripts/package-app.sh --version 0.1.0-local ## 当前边界 -- 第一版只管理 skills,不处理 `AGENTS.md`、规则、命令或 Agent 配置。 +- 规则同步只覆盖文档化的规则入口,不会同步命令、MCP、模型设置或其他 Agent 配置。 - 只支持用户手动添加项目根目录。 - 只支持整目录 `.agents` 软链;不支持部分选择或单技能软链。 - 应用不会自动将既有 `.ai-global/skills` 等非 `.agents` 目录视为已同步;它们会作为冲突显示,避免静默改变项目配置。 From 047982b971afe6e4e981a2afbc61b154cee897c6 Mon Sep 17 00:00:00 2001 From: voidcat Date: Fri, 17 Jul 2026 11:30:04 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(dashboard):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=8A=80=E8=83=BD=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E7=B4=A7=E5=87=91=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/ProjectSkillUpdateService.swift | 13 +++ .../ViewModels/DashboardViewModel.swift | 95 +++++++++++++++---- .../SkillDeck/ViewModels/ProjectManager.swift | 29 ++++++ Sources/SkillDeck/Views/ContentView.swift | 23 ++++- .../Views/Dashboard/DashboardView.swift | 25 +++-- .../Views/Dashboard/SkillRowView.swift | 82 ++++++++++++---- .../ProjectSkillUpdateServiceTests.swift | 23 +++++ 7 files changed, 240 insertions(+), 50 deletions(-) diff --git a/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift b/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift index f82a802..966c55b 100644 --- a/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift +++ b/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift @@ -90,6 +90,19 @@ actor ProjectSkillUpdateService { return try await loadSkill(named: skill.id, at: skill.canonicalURL) } + /// Deletes only this project's source skill directory and its project-local lock entry. + /// The service owns `sourceRoot`, so a dashboard action cannot remove a same-named global skill. + func deleteSkill(named name: String) async throws { + let directory = sourceRoot.appendingPathComponent(name) + let fileManager = FileManager.default + if fileManager.fileExists(atPath: directory.path) { + try fileManager.removeItem(at: directory) + } + if await lockFileManager.exists { + try await lockFileManager.removeEntry(skillName: name) + } + } + private func lockEntry(for skillName: String) async -> LockEntry? { await lockFileManager.invalidateCache() guard await lockFileManager.exists else { return nil } diff --git a/Sources/SkillDeck/ViewModels/DashboardViewModel.swift b/Sources/SkillDeck/ViewModels/DashboardViewModel.swift index bf69116..61e82c3 100644 --- a/Sources/SkillDeck/ViewModels/DashboardViewModel.swift +++ b/Sources/SkillDeck/ViewModels/DashboardViewModel.swift @@ -1,5 +1,20 @@ import Foundation +/// A dashboard row may originate from the configured global store or a specific project store. +/// `Skill.id` alone is not sufficient here because different projects can legitimately use the +/// same directory name, so identity is based on the canonical filesystem path. +struct DashboardSkillItem: Identifiable { + enum Origin { + case global + case project(ProjectDashboardSkill) + } + + let skill: Skill + let origin: Origin + + var id: String { skill.canonicalURL.standardizedFileURL.path } +} + /// DashboardViewModel manages the state and interaction logic for the Dashboard page /// /// In the MVVM architecture, the ViewModel acts as a bridge between View and Model: @@ -31,8 +46,11 @@ final class DashboardViewModel { /// Whether to show delete confirmation dialog var showDeleteConfirmation = false - /// Skill pending deletion - var skillToDelete: Skill? + /// Dashboard row pending deletion. Its origin determines which storage scope is modified. + var itemToDelete: DashboardSkillItem? + + /// Project skills are loaded independently because their lock file is scoped to each project. + var projectItems: [DashboardSkillItem] = [] /// Sort direction enum /// Swift enums can conform to multiple protocols: @@ -75,25 +93,49 @@ final class DashboardViewModel { /// Reference to global SkillManager (dependency injection) let skillManager: SkillManager + let projectManager: ProjectManager - init(skillManager: SkillManager) { + init(skillManager: SkillManager, projectManager: ProjectManager) { self.skillManager = skillManager + self.projectManager = projectManager + } + + /// Exposes the project's revision as a SwiftUI task dependency without duplicating state. + var projectRevision: Int { projectManager.dashboardRevision } + + /// Reads project source skills after ProjectManager has removed per-agent duplicates. + func reloadProjectSkills() async { + var items: [DashboardSkillItem] = [] + for projectSkill in projectManager.dashboardSkills { + let service = ProjectSkillUpdateService(sourceRoot: projectSkill.row.sourceURL.deletingLastPathComponent()) + guard let skill = try? await service.loadSkill(named: projectSkill.row.name, at: projectSkill.row.sourceURL) else { + continue + } + items.append(DashboardSkillItem(skill: skill, origin: .project(projectSkill))) + } + projectItems = items } /// Calculates the list of skills to display based on current search, filter, and sort conditions /// Computed property: dynamically calculated on each access, similar to Java getter - var filteredSkills: [Skill] { - var result = skillManager.skills + var filteredItems: [DashboardSkillItem] { + var result = skillManager.skills.map { DashboardSkillItem(skill: $0, origin: .global) } + projectItems // 1. Search filtering if !searchText.isEmpty { - result = skillManager.search(query: searchText) + let query = searchText.lowercased() + result = result.filter { item in + item.skill.displayName.lowercased().contains(query) + || item.skill.metadata.description.lowercased().contains(query) + || item.skill.id.lowercased().contains(query) + } } // 2. Agent filtering if let agent = selectedAgentFilter { - result = result.filter { skill in - skill.installations.contains { $0.agentType == agent } + result = result.filter { item in + guard case .global = item.origin else { return false } + return item.skill.installations.contains { $0.agentType == agent } } } @@ -104,48 +146,61 @@ final class DashboardViewModel { case .name: result.sort { ascending - ? $0.displayName.lowercased() < $1.displayName.lowercased() - : $0.displayName.lowercased() > $1.displayName.lowercased() + ? $0.skill.displayName.lowercased() < $1.skill.displayName.lowercased() + : $0.skill.displayName.lowercased() > $1.skill.displayName.lowercased() } case .scope: result.sort { ascending - ? $0.scope.displayName < $1.scope.displayName - : $0.scope.displayName > $1.scope.displayName + ? $0.skill.scope.displayName < $1.skill.scope.displayName + : $0.skill.scope.displayName > $1.skill.scope.displayName } case .agent: // Agent Count defaults to descending (most first) for better visibility result.sort { ascending - ? $0.installations.count < $1.installations.count - : $0.installations.count > $1.installations.count + ? $0.skill.installations.count < $1.skill.installations.count + : $0.skill.installations.count > $1.skill.installations.count } } return result } + /// Finds the selected row by its path-based ID, avoiding collisions between same-named skills. + func item(id: String) -> DashboardSkillItem? { + filteredItems.first { $0.id == id } ?? (skillManager.skills.map { DashboardSkillItem(skill: $0, origin: .global) } + projectItems).first { $0.id == id } + } + /// Requests skill deletion (shows confirmation dialog first) - func requestDelete(skill: Skill) { - skillToDelete = skill + func requestDelete(item: DashboardSkillItem) { + itemToDelete = item showDeleteConfirmation = true } /// Confirms deletion func confirmDelete() async { - guard let skill = skillToDelete else { return } + guard let item = itemToDelete else { return } do { - try await skillManager.deleteSkill(skill) + switch item.origin { + case .global: + try await skillManager.deleteSkill(item.skill) + case .project(let projectSkill): + let service = ProjectSkillUpdateService(sourceRoot: projectSkill.row.sourceURL.deletingLastPathComponent()) + try await service.deleteSkill(named: projectSkill.row.name) + projectManager.reload() + await reloadProjectSkills() + } } catch { skillManager.errorMessage = "Delete failed: \(error.localizedDescription)" } - skillToDelete = nil + itemToDelete = nil showDeleteConfirmation = false } /// Cancels deletion func cancelDelete() { - skillToDelete = nil + itemToDelete = nil showDeleteConfirmation = false } } diff --git a/Sources/SkillDeck/ViewModels/ProjectManager.swift b/Sources/SkillDeck/ViewModels/ProjectManager.swift index 0ea9e43..0b5fe2a 100644 --- a/Sources/SkillDeck/ViewModels/ProjectManager.swift +++ b/Sources/SkillDeck/ViewModels/ProjectManager.swift @@ -105,6 +105,17 @@ struct ProjectSkillRow: Identifiable, Hashable { var id: String { targetURL.path } } +/// Represents one canonical project skill in dashboard context. Project inspections contain one +/// copy per AI tool target, so this wrapper keeps the project identity while exposing a single +/// source directory to the dashboard. +struct ProjectDashboardSkill: Identifiable { + let project: ManagedProject + let row: ProjectSkillRow + + /// Absolute source paths remain unique when separate projects use the same skill folder name. + var id: String { row.sourceURL.standardizedFileURL.path } +} + struct ProjectInspection: Identifiable { let project: ManagedProject let agent: ProjectAgent @@ -276,6 +287,8 @@ final class ProjectManager { var ruleApplyErrorMessage: String? var ruleApplyResultMessage: String? var logs: [String] = [] + /// Changes whenever project filesystem inspection is refreshed, allowing dashboard data to reload. + var dashboardRevision = 0 /// Project-local targets and global AI tools intentionally use different path resolvers. /// The global set is the sidebar's AgentType list; projects retain their local configuration paths. @@ -316,6 +329,21 @@ final class ProjectManager { var selectedRuleInspections: [RuleInspection] { ruleInspections.filter { $0.project.id == selectedProjectID } } var linkedRuleCount: Int { selectedRuleInspections.filter { $0.state == .linked }.count } + /// Source skills are repeated once per configured AI tool in `inspections`. The dashboard needs + /// each project skill only once because opening or deleting it always acts on the source directory. + var dashboardSkills: [ProjectDashboardSkill] { + var seenSourcePaths = Set() + var skills: [ProjectDashboardSkill] = [] + for inspection in inspections { + for row in inspection.skills { + let sourcePath = row.sourceURL.standardizedFileURL.path + guard seenSourcePaths.insert(sourcePath).inserted else { continue } + skills.append(ProjectDashboardSkill(project: inspection.project, row: row)) + } + } + return skills + } + func addProject(_ url: URL) { guard fixedProject == nil else { return } let project = ManagedProject(rootPath: url.standardizedFileURL.path) @@ -343,6 +371,7 @@ final class ProjectManager { func reload() { inspections = projects.flatMap { project in targets.map { filesystem.inspect(project: project, agent: $0) } } ruleInspections = projects.flatMap { ruleFileSystem.inspect(project: $0) } + dashboardRevision += 1 log("已扫描 \(projects.count) 个项目") } diff --git a/Sources/SkillDeck/Views/ContentView.swift b/Sources/SkillDeck/Views/ContentView.swift index 82311fc..040903b 100644 --- a/Sources/SkillDeck/Views/ContentView.swift +++ b/Sources/SkillDeck/Views/ContentView.swift @@ -132,8 +132,23 @@ struct ContentView: View { subtitle: L10n.string(L10nKeys.emptySelectSkillSubtitleClawHub, bundle: localizationBundle, locale: locale) ) } - } else if let skillID = selectedSkillID, let vm = detailVM { - SkillDetailView(skillID: skillID, viewModel: vm) + } else if let itemID = selectedSkillID, let dashboardVM { + if let item = dashboardVM.item(id: itemID) { + switch item.origin { + case .global: + if let detailVM { + SkillDetailView(skillID: item.skill.id, viewModel: detailVM) + } + case .project(let projectSkill): + ProjectSkillDetailView(row: projectSkill.row) + } + } else { + EmptyStateView( + icon: "square.stack.3d.up", + title: L10n.string(L10nKeys.emptySelectSkillTitle, bundle: localizationBundle, locale: locale), + subtitle: L10n.string(L10nKeys.emptySelectSkillSubtitleList, bundle: localizationBundle, locale: locale) + ) + } } else { EmptyStateView( icon: "square.stack.3d.up", @@ -144,11 +159,11 @@ struct ContentView: View { } // .task executes async task when View first appears (similar to React's useEffect([], ...)) .task { - dashboardVM = DashboardViewModel(skillManager: skillManager) - detailVM = SkillDetailViewModel(skillManager: skillManager) let manager = ProjectManager() manager.reload() projectManager = manager + dashboardVM = DashboardViewModel(skillManager: skillManager, projectManager: manager) + detailVM = SkillDetailViewModel(skillManager: skillManager) let globalManager = ProjectManager(fixedProject: ManagedProject(rootPath: NSHomeDirectory(), displayName: "全局技能", usesConfiguredGlobalSource: true)) globalManager.reload() globalSyncManager = globalManager diff --git a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift index bf167c8..0091db9 100644 --- a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift +++ b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift @@ -22,7 +22,7 @@ struct DashboardView: View { // Show progress indicator on first load ProgressView("Scanning skills...") .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.filteredSkills.isEmpty { + } else if viewModel.filteredItems.isEmpty { // Empty state EmptyStateView( icon: "magnifyingglass", @@ -33,20 +33,20 @@ struct DashboardView: View { ) } else { // Skill list - List(viewModel.filteredSkills, selection: $selectedSkillID) { skill in - SkillRowView(skill: skill) - .tag(skill.id) + List(viewModel.filteredItems, selection: $selectedSkillID) { item in + SkillRowView(skill: item.skill, projectName: projectName(for: item)) + .tag(item.id) // contextMenu is macOS's right-click menu .contextMenu { Button("Open in Finder") { NSWorkspace.shared.selectFile( nil, - inFileViewerRootedAtPath: skill.canonicalURL.path + inFileViewerRootedAtPath: item.skill.canonicalURL.path ) } Divider() // Menu separator Button("Delete", role: .destructive) { - viewModel.requestDelete(skill: skill) + viewModel.requestDelete(item: item) } } } @@ -54,6 +54,10 @@ struct DashboardView: View { } } .navigationTitle(navigationTitle) + // Project changes refresh the source-skill rows without waiting for an app relaunch. + .task(id: viewModel.projectRevision) { + await viewModel.reloadProjectSkills() + } // Search bar (macOS standard search field, displayed in toolbar) .searchable(text: $viewModel.searchText, prompt: "Search skills...") // Toolbar: sorting and filtering @@ -139,8 +143,8 @@ struct DashboardView: View { Task { await viewModel.confirmDelete() } } } message: { - if let skill = viewModel.skillToDelete { - Text("Are you sure you want to delete \"\(skill.displayName)\"? This will remove the skill directory and all symlinks. This action cannot be undone.") + if let item = viewModel.itemToDelete { + Text("Are you sure you want to delete \"\(item.skill.displayName)\"? This action cannot be undone.") } } // Error message @@ -170,6 +174,11 @@ struct DashboardView: View { return "All Skills" } + private func projectName(for item: DashboardSkillItem) -> String? { + guard case .project(let projectSkill) = item.origin else { return nil } + return projectSkill.project.name + } + private var languageBadge: String { switch AppLanguage(storedRawValue: appLanguageRaw) { case .system: diff --git a/Sources/SkillDeck/Views/Dashboard/SkillRowView.swift b/Sources/SkillDeck/Views/Dashboard/SkillRowView.swift index 5ff3bde..7d47cfd 100644 --- a/Sources/SkillDeck/Views/Dashboard/SkillRowView.swift +++ b/Sources/SkillDeck/Views/Dashboard/SkillRowView.swift @@ -2,10 +2,17 @@ import SwiftUI /// SkillRowView is the skill card for each row in the list /// -/// Displays skill name, description, scope badge, and installed Agent icons +/// Displays skill name, description, scope badge, and a compact installed Agent summary. struct SkillRowView: View { let skill: Skill + /// Project rows carry their source project name so same-named skills remain distinguishable. + let projectName: String? + + init(skill: Skill, projectName: String? = nil) { + self.skill = skill + self.projectName = projectName + } /// Get SkillManager from environment for reading updateStatuses dictionary /// @Environment is SwiftUI's dependency injection mechanism (similar to Spring's @Autowired) @@ -14,30 +21,44 @@ struct SkillRowView: View { var body: some View { VStack(alignment: .leading, spacing: 6) { // First row: name + badges - HStack { - Text(skill.displayName).appFont(.headline) + HStack(spacing: 6) { + Text(skill.displayName) + .appFont(.headline) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) ScopeBadge(scope: skill.scope) + .fixedSize() // F12: Display different indicator icons based on update check status updateStatusIndicator - Spacer() - - // Installed Agent icon row - // Use installations instead of installedAgents to get isTrulyInherited information - // Inherited installation icons have reduced opacity, hover tooltip shows source - HStack(spacing: 4) { - ForEach(skill.installations) { installation in - Image(systemName: installation.agentType.iconName).appFont(.caption) - .foregroundStyle(Constants.AgentColors.color(for: installation.agentType)) - // Reduce opacity for inherited installation icons to visually distinguish from direct installations - .opacity(installation.isTrulyInherited ? 0.4 : 1.0) - // Hover tooltip: inherited installation shows "Copilot CLI (via ~/.claude/skills)" - .help(installation.isTrulyInherited - ? "\(installation.agentType.displayName) (via \(installation.parentDirectoryDisplayPath))" - : installation.agentType.displayName) + Spacer(minLength: 4) + + // The dashboard column is intentionally narrow. Showing every installed agent here + // caused names and scope badges to wrap, so the row shows a stable three-icon preview. + if !skill.installations.isEmpty { + HStack(spacing: 3) { + ForEach(visibleInstallations) { installation in + Image(systemName: installation.agentType.iconName).appFont(.caption) + .foregroundStyle(Constants.AgentColors.color(for: installation.agentType)) + // Reduce opacity for inherited installation icons to visually distinguish from direct installations + .opacity(installation.isTrulyInherited ? 0.4 : 1.0) + // Hover tooltip: inherited installation shows "Copilot CLI (via ~/.claude/skills)" + .help(installation.isTrulyInherited + ? "\(installation.agentType.displayName) (via \(installation.parentDirectoryDisplayPath))" + : installation.agentType.displayName) + } + if hiddenInstallationCount > 0 { + Text("+\(hiddenInstallationCount)") + .appFont(.caption2) + .foregroundStyle(.secondary) + .monospacedDigit() + } } + .fixedSize() + .help(installationSummary) } } @@ -64,6 +85,13 @@ struct SkillRowView: View { Label(lockEntry.source, systemImage: "link").appFont(.caption) .foregroundStyle(.tertiary) } + + if let projectName { + Label(projectName, systemImage: "folder") + .appFont(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } } } .padding(.vertical, 4) @@ -71,6 +99,24 @@ struct SkillRowView: View { // MARK: - Update Status Indicator + /// The list row previews a bounded number of agents; details still expose every installation. + private var visibleInstallations: ArraySlice { + skill.installations.prefix(3) + } + + private var hiddenInstallationCount: Int { + max(skill.installations.count - visibleInstallations.count, 0) + } + + /// Tooltip keeps the complete installation information available without expanding the row. + private var installationSummary: String { + skill.installations.map { installation in + installation.isTrulyInherited + ? "\(installation.agentType.displayName) (via \(installation.parentDirectoryDisplayPath))" + : installation.agentType.displayName + }.joined(separator: ", ") + } + /// Renders different status indicators based on SkillUpdateStatus enum /// /// @ViewBuilder allows using conditional branches in computed properties to return different View types, diff --git a/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift b/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift index 747bc41..3eeccbe 100644 --- a/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift +++ b/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift @@ -49,4 +49,27 @@ final class ProjectSkillUpdateServiceTests: XCTestCase { XCTAssertEqual(skill.lockEntry, expectedEntry) XCTAssertTrue(FileManager.default.fileExists(atPath: projectLockURL.path)) } + + func testDeleteRemovesOnlyProjectSkillAndProjectLockEntry() async throws { + let secondSkill = sourceRoot.appendingPathComponent("keep") + try FileManager.default.createDirectory(at: secondSkill, withIntermediateDirectories: true) + try "---\nname: Keep\n---\n".write(to: secondSkill.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + let lockManager = LockFileManager(filePath: projectRoot.appendingPathComponent(".agents/.skill-lock.json")) + try await lockManager.createIfNotExists() + let entry = LockEntry(source: "owner/repository", sourceType: "github", sourceUrl: "https://github.com/owner/repository.git", skillPath: "demo/SKILL.md", skillFolderHash: "hash", installedAt: "2026-07-14T00:00:00Z", updatedAt: "2026-07-14T00:00:00Z") + try await lockManager.updateEntry(skillName: "demo", entry: entry) + try await lockManager.updateEntry(skillName: "keep", entry: entry) + + let service = ProjectSkillUpdateService(sourceRoot: sourceRoot) + try await service.deleteSkill(named: "demo") + + await lockManager.invalidateCache() + let deletedEntry = try await lockManager.getEntry(skillName: "demo") + let retainedEntry = try await lockManager.getEntry(skillName: "keep") + XCTAssertFalse(FileManager.default.fileExists(atPath: skillDirectory.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: secondSkill.path)) + XCTAssertNil(deletedEntry) + XCTAssertEqual(retainedEntry, entry) + } } From fbd74f2a0ce7f3d9d98f2301b5b1dd2408e3ad07 Mon Sep 17 00:00:00 2001 From: voidcat Date: Fri, 17 Jul 2026 11:41:55 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(skills):=20=E6=94=AF=E6=8C=81=E5=8F=AF?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=A6=81=E7=94=A8=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/DisabledSkillStore.swift | 104 ++++++++++++++++++ .../Services/ProjectSkillUpdateService.swift | 19 ++-- Sources/SkillDeck/Services/SkillManager.swift | 21 +--- .../ViewModels/DashboardViewModel.swift | 63 ++++++++--- .../Views/Dashboard/DashboardView.swift | 26 ++++- .../Views/Dashboard/DisabledSkillsSheet.swift | 48 ++++++++ .../DisabledSkillStoreTests.swift | 43 ++++++++ .../ProjectSkillUpdateServiceTests.swift | 13 ++- 8 files changed, 280 insertions(+), 57 deletions(-) create mode 100644 Sources/SkillDeck/Services/DisabledSkillStore.swift create mode 100644 Sources/SkillDeck/Views/Dashboard/DisabledSkillsSheet.swift create mode 100644 Tests/SkillDeckTests/DisabledSkillStoreTests.swift diff --git a/Sources/SkillDeck/Services/DisabledSkillStore.swift b/Sources/SkillDeck/Services/DisabledSkillStore.swift new file mode 100644 index 0000000..4a49ea8 --- /dev/null +++ b/Sources/SkillDeck/Services/DisabledSkillStore.swift @@ -0,0 +1,104 @@ +import Foundation + +/// DisabledSkillRecord persists the original location for one skill moved out of its active source. +/// Keeping this record under the user's shared `.agents` directory avoids modifying a project worktree +/// and lets the app restore the directory after a branch switch or app restart. +struct DisabledSkillRecord: Codable, Equatable, Identifiable { + let id: UUID + let displayName: String + let originalPath: String + let archivedPath: String + let scopeLabel: String + let projectName: String? + let disabledAt: Date +} + +/// DisabledSkillStore provides reversible skill disablement outside any project repository. +/// FileManager.moveItem preserves every file in the skill directory and is reversible by moving the +/// same directory back, unlike a delete operation which permanently removes the skill contents. +struct DisabledSkillStore { + private struct ArchiveIndex: Codable { + var records: [DisabledSkillRecord] + } + + /// The shared archive deliberately lives under the home-level `.agents` directory rather than a + /// project `.agents` directory, so it is not tracked, ignored, or removed by project branch changes. + static var defaultRoot: URL { + URL(fileURLWithPath: NSString(string: "~/.agents/.skilldeck-disabled").expandingTildeInPath) + } + + private let root: URL + private let fileManager: FileManager + + init(root: URL = DisabledSkillStore.defaultRoot, fileManager: FileManager = .default) { + self.root = root + self.fileManager = fileManager + } + + /// Moves an active skill directory into the central archive and records the exact restore path. + func disable(skill: Skill, projectName: String? = nil) throws -> DisabledSkillRecord { + try fileManager.createDirectory(at: entriesURL, withIntermediateDirectories: true) + let id = UUID() + let archiveURL = entriesURL.appendingPathComponent(id.uuidString, isDirectory: true) + try fileManager.moveItem(at: skill.canonicalURL, to: archiveURL) + + let record = DisabledSkillRecord( + id: id, + displayName: skill.displayName, + originalPath: skill.canonicalURL.path, + archivedPath: archiveURL.path, + scopeLabel: skill.scope.displayName, + projectName: projectName, + disabledAt: Date() + ) + var index = try readIndex() + index.records.append(record) + try writeIndex(index) + return record + } + + /// Restores a disabled directory to its recorded original path. Existing active content is never + /// overwritten because it could be a newer skill created while the archived one was disabled. + func restore(_ record: DisabledSkillRecord) throws { + let originalURL = URL(fileURLWithPath: record.originalPath) + guard !fileManager.fileExists(atPath: originalURL.path) else { + throw DisabledSkillStoreError.restoreDestinationExists(originalURL.path) + } + try fileManager.createDirectory(at: originalURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.moveItem(at: URL(fileURLWithPath: record.archivedPath), to: originalURL) + + var index = try readIndex() + index.records.removeAll { $0.id == record.id } + try writeIndex(index) + } + + func records() throws -> [DisabledSkillRecord] { + try readIndex().records.sorted { $0.disabledAt > $1.disabledAt } + } + + private var entriesURL: URL { root.appendingPathComponent("entries", isDirectory: true) } + private var indexURL: URL { root.appendingPathComponent("index.json") } + + private func readIndex() throws -> ArchiveIndex { + guard fileManager.fileExists(atPath: indexURL.path) else { return ArchiveIndex(records: []) } + return try JSONDecoder().decode(ArchiveIndex.self, from: Data(contentsOf: indexURL)) + } + + private func writeIndex(_ index: ArchiveIndex) throws { + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(index).write(to: indexURL, options: .atomic) + } +} + +enum DisabledSkillStoreError: LocalizedError { + case restoreDestinationExists(String) + + var errorDescription: String? { + switch self { + case .restoreDestinationExists(let path): + return "无法恢复,原位置已有技能:\(path)" + } + } +} diff --git a/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift b/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift index 966c55b..d7d31c7 100644 --- a/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift +++ b/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift @@ -12,9 +12,11 @@ actor ProjectSkillUpdateService { private let sourceRoot: URL private let lockFileManager: LockFileManager private let gitService = GitService() + private let disabledSkillStore: DisabledSkillStore - init(sourceRoot: URL) { + init(sourceRoot: URL, disabledSkillStore: DisabledSkillStore = DisabledSkillStore()) { self.sourceRoot = sourceRoot + self.disabledSkillStore = disabledSkillStore let agentsRoot = sourceRoot.deletingLastPathComponent() self.lockFileManager = LockFileManager(filePath: agentsRoot.appendingPathComponent(".skill-lock.json")) } @@ -90,17 +92,10 @@ actor ProjectSkillUpdateService { return try await loadSkill(named: skill.id, at: skill.canonicalURL) } - /// Deletes only this project's source skill directory and its project-local lock entry. - /// The service owns `sourceRoot`, so a dashboard action cannot remove a same-named global skill. - func deleteSkill(named name: String) async throws { - let directory = sourceRoot.appendingPathComponent(name) - let fileManager = FileManager.default - if fileManager.fileExists(atPath: directory.path) { - try fileManager.removeItem(at: directory) - } - if await lockFileManager.exists { - try await lockFileManager.removeEntry(skillName: name) - } + /// Moves a project skill into the home-level archive without writing archive files into the project. + /// The project lock entry is intentionally retained so repository metadata works after restoration. + func disableSkill(_ skill: Skill, projectName: String) throws { + _ = try disabledSkillStore.disable(skill: skill, projectName: projectName) } private func lockEntry(for skillName: String) async -> LockEntry? { diff --git a/Sources/SkillDeck/Services/SkillManager.swift b/Sources/SkillDeck/Services/SkillManager.swift index b2210e2..6e6c8e5 100644 --- a/Sources/SkillDeck/Services/SkillManager.swift +++ b/Sources/SkillDeck/Services/SkillManager.swift @@ -313,23 +313,12 @@ final class SkillManager { watcher.startWatching(paths: paths) } - // MARK: - F04: Skill Deletion + // MARK: - F04: Skill Disablement - /// Delete a skill from the configured canonical directory and remove its lock entry. - /// Whole-directory links remain valid because they point at the containing directory, not this skill. - func deleteSkill(_ skill: Skill) async throws { - // 1. Delete canonical directory - let fm = FileManager.default - if fm.fileExists(atPath: skill.canonicalURL.path) { - try fm.removeItem(at: skill.canonicalURL) - } - - // 2. Update lock file (if there's a record) - if skill.lockEntry != nil { - try await lockFileManager.removeEntry(skillName: skill.id) - } - - // 3. Refresh list + /// Disables a skill by moving it to the central reversible archive. Lock metadata stays intact, + /// so restoring the directory also restores its repository update information and symlink targets. + func disableSkill(_ skill: Skill) async throws { + _ = try DisabledSkillStore().disable(skill: skill) await refresh() } diff --git a/Sources/SkillDeck/ViewModels/DashboardViewModel.swift b/Sources/SkillDeck/ViewModels/DashboardViewModel.swift index 61e82c3..b818797 100644 --- a/Sources/SkillDeck/ViewModels/DashboardViewModel.swift +++ b/Sources/SkillDeck/ViewModels/DashboardViewModel.swift @@ -43,15 +43,19 @@ final class DashboardViewModel { /// Currently selected skill (used for navigation to detail page) var selectedSkillID: String? - /// Whether to show delete confirmation dialog - var showDeleteConfirmation = false + /// Whether to show disable confirmation dialog + var showDisableConfirmation = false - /// Dashboard row pending deletion. Its origin determines which storage scope is modified. - var itemToDelete: DashboardSkillItem? + /// Dashboard row pending disablement. Its origin determines which storage scope is modified. + var itemToDisable: DashboardSkillItem? /// Project skills are loaded independently because their lock file is scoped to each project. var projectItems: [DashboardSkillItem] = [] + /// Reversible archive records shown from the dashboard toolbar. + var disabledSkills: [DisabledSkillRecord] = [] + var showsDisabledSkills = false + /// Sort direction enum /// Swift enums can conform to multiple protocols: /// - CaseIterable: provides allCases collection for iterating over enum values @@ -116,6 +120,28 @@ final class DashboardViewModel { projectItems = items } + /// Reloads the shared archive index; the archive is outside project directories by design. + func reloadDisabledSkills() { + do { + disabledSkills = try DisabledSkillStore().records() + } catch { + skillManager.errorMessage = "读取已禁用技能失败:\(error.localizedDescription)" + } + } + + /// Restores the archived directory to its original global or project source and refreshes both lists. + func restoreDisabledSkill(_ record: DisabledSkillRecord) async { + do { + try DisabledSkillStore().restore(record) + await skillManager.refresh() + projectManager.reload() + await reloadProjectSkills() + reloadDisabledSkills() + } catch { + skillManager.errorMessage = error.localizedDescription + } + } + /// Calculates the list of skills to display based on current search, filter, and sort conditions /// Computed property: dynamically calculated on each access, similar to Java getter var filteredItems: [DashboardSkillItem] { @@ -172,35 +198,36 @@ final class DashboardViewModel { filteredItems.first { $0.id == id } ?? (skillManager.skills.map { DashboardSkillItem(skill: $0, origin: .global) } + projectItems).first { $0.id == id } } - /// Requests skill deletion (shows confirmation dialog first) - func requestDelete(item: DashboardSkillItem) { - itemToDelete = item - showDeleteConfirmation = true + /// Requests reversible skill disablement (shows confirmation dialog first). + func requestDisable(item: DashboardSkillItem) { + itemToDisable = item + showDisableConfirmation = true } - /// Confirms deletion + /// Confirms disablement func confirmDelete() async { - guard let item = itemToDelete else { return } + guard let item = itemToDisable else { return } do { switch item.origin { case .global: - try await skillManager.deleteSkill(item.skill) + try await skillManager.disableSkill(item.skill) case .project(let projectSkill): let service = ProjectSkillUpdateService(sourceRoot: projectSkill.row.sourceURL.deletingLastPathComponent()) - try await service.deleteSkill(named: projectSkill.row.name) + try await service.disableSkill(item.skill, projectName: projectSkill.project.name) projectManager.reload() await reloadProjectSkills() } + reloadDisabledSkills() } catch { - skillManager.errorMessage = "Delete failed: \(error.localizedDescription)" + skillManager.errorMessage = "禁用技能失败:\(error.localizedDescription)" } - itemToDelete = nil - showDeleteConfirmation = false + itemToDisable = nil + showDisableConfirmation = false } - /// Cancels deletion + /// Cancels disablement func cancelDelete() { - itemToDelete = nil - showDeleteConfirmation = false + itemToDisable = nil + showDisableConfirmation = false } } diff --git a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift index 0091db9..944c059 100644 --- a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift +++ b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift @@ -45,8 +45,8 @@ struct DashboardView: View { ) } Divider() // Menu separator - Button("Delete", role: .destructive) { - viewModel.requestDelete(item: item) + Button("禁用") { + viewModel.requestDisable(item: item) } } } @@ -57,6 +57,7 @@ struct DashboardView: View { // Project changes refresh the source-skill rows without waiting for an app relaunch. .task(id: viewModel.projectRevision) { await viewModel.reloadProjectSkills() + viewModel.reloadDisabledSkills() } // Search bar (macOS standard search field, displayed in toolbar) .searchable(text: $viewModel.searchText, prompt: "Search skills...") @@ -132,21 +133,34 @@ struct DashboardView: View { } } } + + ToolbarItem(placement: .automatic) { + Button { + viewModel.reloadDisabledSkills() + viewModel.showsDisabledSkills = true + } label: { + Label("已禁用技能", systemImage: "archivebox") + } + .help("查看并恢复已禁用的技能") + } } // Delete confirmation dialog // .alert similar to Android's AlertDialog or Web's confirm() - .alert("Delete Skill", isPresented: $viewModel.showDeleteConfirmation) { + .alert("禁用技能", isPresented: $viewModel.showDisableConfirmation) { Button("Cancel", role: .cancel) { viewModel.cancelDelete() } - Button("Delete", role: .destructive) { + Button("禁用") { Task { await viewModel.confirmDelete() } } } message: { - if let item = viewModel.itemToDelete { - Text("Are you sure you want to delete \"\(item.skill.displayName)\"? This action cannot be undone.") + if let item = viewModel.itemToDisable { + Text("\"\(item.skill.displayName)\" 会移至 ~/.agents/.skilldeck-disabled,不会删除;可从“已禁用技能”恢复。") } } + .sheet(isPresented: $viewModel.showsDisabledSkills) { + DisabledSkillsSheet(viewModel: viewModel) + } // Error message .overlay(alignment: .bottom) { if let error = skillManager.errorMessage { diff --git a/Sources/SkillDeck/Views/Dashboard/DisabledSkillsSheet.swift b/Sources/SkillDeck/Views/Dashboard/DisabledSkillsSheet.swift new file mode 100644 index 0000000..188927b --- /dev/null +++ b/Sources/SkillDeck/Views/Dashboard/DisabledSkillsSheet.swift @@ -0,0 +1,48 @@ +import SwiftUI + +/// DisabledSkillsSheet provides the user-facing recovery path for skills archived outside projects. +/// A sheet keeps disabled items separate from active dashboard rows, so archived skills cannot be +/// mistaken for currently available skills while still remaining one click away to restore. +struct DisabledSkillsSheet: View { + @Bindable var viewModel: DashboardViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if viewModel.disabledSkills.isEmpty { + ContentUnavailableView("没有已禁用技能", systemImage: "archivebox") + } else { + List(viewModel.disabledSkills) { record in + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(record.displayName).appFont(.headline) + Text(record.projectName ?? record.scopeLabel) + .appFont(.caption) + .foregroundStyle(.secondary) + Text(NSString(string: record.originalPath).abbreviatingWithTildeInPath) + .appFont(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer() + Button("恢复") { + Task { await viewModel.restoreDisabledSkill(record) } + } + .buttonStyle(.borderedProminent) + } + .padding(.vertical, 3) + } + } + } + .navigationTitle("已禁用技能") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭") { dismiss() } + } + } + } + .frame(minWidth: 520, minHeight: 320) + .task { viewModel.reloadDisabledSkills() } + } +} diff --git a/Tests/SkillDeckTests/DisabledSkillStoreTests.swift b/Tests/SkillDeckTests/DisabledSkillStoreTests.swift new file mode 100644 index 0000000..e73742a --- /dev/null +++ b/Tests/SkillDeckTests/DisabledSkillStoreTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import SkillDeck + +/// Covers the reversible archive independently from global or project scanner state. +final class DisabledSkillStoreTests: XCTestCase { + private var root: URL! + private var sourceSkill: URL! + private var archiveRoot: URL! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("SkillDeckDisabled-\(UUID().uuidString)") + sourceSkill = root.appendingPathComponent("skills/demo") + archiveRoot = root.appendingPathComponent("archive") + try FileManager.default.createDirectory(at: sourceSkill, withIntermediateDirectories: true) + try "---\nname: Demo\n---\n".write(to: sourceSkill.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + func testDisableAndRestorePreservesOriginalDirectory() throws { + let skill = Skill( + id: "demo", + canonicalURL: sourceSkill, + metadata: SkillMetadata(name: "Demo", description: "Test", license: nil, metadata: nil, allowedTools: nil), + markdownBody: "", + scope: .sharedGlobal, + installations: [], + lockEntry: nil + ) + let store = DisabledSkillStore(root: archiveRoot) + + let record = try store.disable(skill: skill) + XCTAssertFalse(FileManager.default.fileExists(atPath: sourceSkill.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: URL(fileURLWithPath: record.archivedPath).appendingPathComponent("SKILL.md").path)) + XCTAssertEqual(try store.records(), [record]) + + try store.restore(record) + XCTAssertTrue(FileManager.default.fileExists(atPath: sourceSkill.appendingPathComponent("SKILL.md").path)) + XCTAssertTrue(try store.records().isEmpty) + } +} diff --git a/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift b/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift index 3eeccbe..b25e2de 100644 --- a/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift +++ b/Tests/SkillDeckTests/ProjectSkillUpdateServiceTests.swift @@ -6,11 +6,13 @@ final class ProjectSkillUpdateServiceTests: XCTestCase { private var projectRoot: URL! private var sourceRoot: URL! private var skillDirectory: URL! + private var archiveRoot: URL! override func setUpWithError() throws { projectRoot = FileManager.default.temporaryDirectory.appendingPathComponent("SkillDeckProjectUpdate-\(UUID().uuidString)") sourceRoot = projectRoot.appendingPathComponent(".agents/skills") skillDirectory = sourceRoot.appendingPathComponent("demo") + archiveRoot = projectRoot.appendingPathComponent("archive") try FileManager.default.createDirectory(at: skillDirectory, withIntermediateDirectories: true) try "---\nname: Demo\ndescription: Project test\n---\n\n# Demo\n".write( to: skillDirectory.appendingPathComponent("SKILL.md"), @@ -38,7 +40,7 @@ final class ProjectSkillUpdateServiceTests: XCTestCase { ) try await lockManager.updateEntry(skillName: "demo", entry: expectedEntry) - let service = ProjectSkillUpdateService(sourceRoot: sourceRoot) + let service = ProjectSkillUpdateService(sourceRoot: sourceRoot, disabledSkillStore: DisabledSkillStore(root: archiveRoot)) let skill = try await service.loadSkill(named: "demo", at: skillDirectory) XCTAssertEqual(skill.canonicalURL, skillDirectory) @@ -50,7 +52,7 @@ final class ProjectSkillUpdateServiceTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: projectLockURL.path)) } - func testDeleteRemovesOnlyProjectSkillAndProjectLockEntry() async throws { + func testDisableMovesOnlyProjectSkillAndKeepsProjectLockEntry() async throws { let secondSkill = sourceRoot.appendingPathComponent("keep") try FileManager.default.createDirectory(at: secondSkill, withIntermediateDirectories: true) try "---\nname: Keep\n---\n".write(to: secondSkill.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) @@ -61,15 +63,16 @@ final class ProjectSkillUpdateServiceTests: XCTestCase { try await lockManager.updateEntry(skillName: "demo", entry: entry) try await lockManager.updateEntry(skillName: "keep", entry: entry) - let service = ProjectSkillUpdateService(sourceRoot: sourceRoot) - try await service.deleteSkill(named: "demo") + let service = ProjectSkillUpdateService(sourceRoot: sourceRoot, disabledSkillStore: DisabledSkillStore(root: archiveRoot)) + let skill = try await service.loadSkill(named: "demo", at: skillDirectory) + try await service.disableSkill(skill, projectName: "Test Project") await lockManager.invalidateCache() let deletedEntry = try await lockManager.getEntry(skillName: "demo") let retainedEntry = try await lockManager.getEntry(skillName: "keep") XCTAssertFalse(FileManager.default.fileExists(atPath: skillDirectory.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: secondSkill.path)) - XCTAssertNil(deletedEntry) + XCTAssertEqual(deletedEntry, entry) XCTAssertEqual(retainedEntry, entry) } } From 40853a253f0b152eeb23aab9eb32368475b36332 Mon Sep 17 00:00:00 2001 From: voidcat Date: Fri, 17 Jul 2026 12:02:42 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(dashboard):=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E6=8A=80=E8=83=BD=E6=81=A2=E5=A4=8D=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Dashboard/DashboardView.swift | 85 ++++++++++++------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift index 944c059..781c8c8 100644 --- a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift +++ b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift @@ -17,40 +17,59 @@ struct DashboardView: View { @Environment(\.locale) private var locale var body: some View { - Group { - if skillManager.isLoading && skillManager.skills.isEmpty { - // Show progress indicator on first load - ProgressView("Scanning skills...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.filteredItems.isEmpty { - // Empty state - EmptyStateView( - icon: "magnifyingglass", - title: "No Skills Found", - subtitle: viewModel.searchText.isEmpty - ? "Install skills using npx skills add or the CLI" - : "No skills match your search" - ) - } else { - // Skill list - List(viewModel.filteredItems, selection: $selectedSkillID) { item in - SkillRowView(skill: item.skill, projectName: projectName(for: item)) - .tag(item.id) - // contextMenu is macOS's right-click menu - .contextMenu { - Button("Open in Finder") { - NSWorkspace.shared.selectFile( - nil, - inFileViewerRootedAtPath: item.skill.canonicalURL.path - ) - } - Divider() // Menu separator - Button("禁用") { - viewModel.requestDisable(item: item) + VStack(spacing: 0) { + // This content-level action stays visible even when the toolbar overflows or disabling + // the final active skill leaves the dashboard in its empty state. + if !viewModel.disabledSkills.isEmpty { + HStack { + Button { + viewModel.showsDisabledSkills = true + } label: { + Label("已禁用技能(\(viewModel.disabledSkills.count))", systemImage: "archivebox") + } + .buttonStyle(.bordered) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + + Group { + if skillManager.isLoading && skillManager.skills.isEmpty { + // Show progress indicator on first load + ProgressView("Scanning skills...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.filteredItems.isEmpty { + // Empty state + EmptyStateView( + icon: "magnifyingglass", + title: "No Skills Found", + subtitle: viewModel.searchText.isEmpty + ? "Install skills using npx skills add or the CLI" + : "No skills match your search" + ) + } else { + // Skill list + List(viewModel.filteredItems, selection: $selectedSkillID) { item in + SkillRowView(skill: item.skill, projectName: projectName(for: item)) + .tag(item.id) + // contextMenu is macOS's right-click menu + .contextMenu { + Button("Open in Finder") { + NSWorkspace.shared.selectFile( + nil, + inFileViewerRootedAtPath: item.skill.canonicalURL.path + ) + } + Divider() // Menu separator + Button("禁用") { + viewModel.requestDisable(item: item) + } } - } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) } - .listStyle(.inset(alternatesRowBackgrounds: true)) } } .navigationTitle(navigationTitle) @@ -144,7 +163,7 @@ struct DashboardView: View { .help("查看并恢复已禁用的技能") } } - // Delete confirmation dialog + // Disable confirmation dialog // .alert similar to Android's AlertDialog or Web's confirm() .alert("禁用技能", isPresented: $viewModel.showDisableConfirmation) { Button("Cancel", role: .cancel) { From dd58c859bdc35b7cc2096c1191b77cf6e0b11b64 Mon Sep 17 00:00:00 2001 From: voidcat Date: Fri, 17 Jul 2026 13:57:23 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat(dashboard):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=8C=83=E5=9B=B4=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModels/DashboardViewModel.swift | 54 +++++++++++++++++-- .../Views/Dashboard/DashboardView.swift | 24 +++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/Sources/SkillDeck/ViewModels/DashboardViewModel.swift b/Sources/SkillDeck/ViewModels/DashboardViewModel.swift index b818797..103f73f 100644 --- a/Sources/SkillDeck/ViewModels/DashboardViewModel.swift +++ b/Sources/SkillDeck/ViewModels/DashboardViewModel.swift @@ -15,6 +15,38 @@ struct DashboardSkillItem: Identifiable { var id: String { skill.canonicalURL.standardizedFileURL.path } } +/// Controls whether the dashboard shows all active skills or only one storage scope. +/// This is separate from the Agent sidebar filter because a project skill is shared by project +/// configuration directories rather than installed into one user-level Agent directory. +enum DashboardScopeFilter: CaseIterable, Identifiable { + case all + case global + case project + + var id: Self { self } + + var title: String { + switch self { + case .all: "全部" + case .global: "全局" + case .project: "项目" + } + } + + func includes(_ skill: Skill) -> Bool { + switch self { + case .all: + return true + case .global: + if case .sharedGlobal = skill.scope { return true } + return false + case .project: + if case .project = skill.scope { return true } + return false + } + } +} + /// DashboardViewModel manages the state and interaction logic for the Dashboard page /// /// In the MVVM architecture, the ViewModel acts as a bridge between View and Model: @@ -34,6 +66,12 @@ final class DashboardViewModel { /// Currently selected Agent filter (nil means show all) var selectedAgentFilter: AgentType? + /// Storage scope filter displayed above the dashboard list. + var scopeFilter: DashboardScopeFilter = .all + + /// Optional project identity used only while the project scope filter is active. + var selectedProjectFilterID: String? + /// Sort order var sortOrder: SortOrder = .name @@ -147,7 +185,17 @@ final class DashboardViewModel { var filteredItems: [DashboardSkillItem] { var result = skillManager.skills.map { DashboardSkillItem(skill: $0, origin: .global) } + projectItems - // 1. Search filtering + // 1. Scope filtering + result = result.filter { scopeFilter.includes($0.skill) } + + if scopeFilter == .project, let selectedProjectFilterID { + result = result.filter { item in + guard case .project(let projectSkill) = item.origin else { return false } + return projectSkill.project.id == selectedProjectFilterID + } + } + + // 2. Search filtering if !searchText.isEmpty { let query = searchText.lowercased() result = result.filter { item in @@ -157,7 +205,7 @@ final class DashboardViewModel { } } - // 2. Agent filtering + // 3. Agent filtering if let agent = selectedAgentFilter { result = result.filter { item in guard case .global = item.origin else { return false } @@ -165,7 +213,7 @@ final class DashboardViewModel { } } - // 3. Sorting (ascending or descending based on sort direction) + // 4. Sorting (ascending or descending based on sort direction) // In Swift closures, $0 and $1 are anonymous parameters, similar to Kotlin's it let ascending = sortDirection == .ascending switch sortOrder { diff --git a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift index 781c8c8..4a21d79 100644 --- a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift +++ b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift @@ -35,6 +35,30 @@ struct DashboardView: View { .background(.bar) } + // Segmented controls are the native compact control for mutually exclusive display modes. + Picker("技能范围", selection: $viewModel.scopeFilter) { + ForEach(DashboardScopeFilter.allCases) { filter in + Text(filter.title).tag(filter) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + + if viewModel.scopeFilter == .project { + Picker("项目", selection: $viewModel.selectedProjectFilterID) { + Text("全部项目").tag(String?.none) + ForEach(viewModel.projectManager.projects) { project in + Text(project.name).tag(Optional(project.id)) + } + } + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.bottom, 8) + } + Group { if skillManager.isLoading && skillManager.skills.isEmpty { // Show progress indicator on first load From f4c9b7c765cc88a69d5f139ae782dea4a8b79596 Mon Sep 17 00:00:00 2001 From: voidcat Date: Mon, 20 Jul 2026 16:41:48 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(i18n):=20=E8=A1=A5=E5=85=A8=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=90=8C=E6=AD=A5=E7=95=8C=E9=9D=A2=E5=8F=8C=E8=AF=AD?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=AF=AD=E8=A8=80=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../en.lproj/Localizable.strings | 120 +++++++++++++++++ .../zh-Hans.lproj/Localizable.strings | 120 +++++++++++++++++ .../Services/DisabledSkillStore.swift | 2 +- .../Services/ProjectSkillUpdateService.swift | 4 +- .../Services/RuleSyncFileSystem.swift | 51 ++++--- .../Utilities/Localization/L10n.swift | 20 +++ .../Utilities/Localization/L10nKeys.swift | 127 ++++++++++++++++++ .../Localization/LocalizationResolver.swift | 19 +-- .../ViewModels/DashboardViewModel.swift | 10 +- .../SkillDeck/ViewModels/ProjectManager.swift | 40 +++--- .../ProjectSkillDetailViewModel.swift | 6 +- Sources/SkillDeck/Views/ContentView.swift | 6 +- .../Views/Dashboard/DashboardView.swift | 22 +-- .../Views/Dashboard/DisabledSkillsSheet.swift | 8 +- .../Projects/GlobalSkillSourceList.swift | 8 +- .../Projects/ProjectManagementView.swift | 106 +++++++-------- .../Projects/ProjectSkillDetailView.swift | 24 ++-- .../SkillDeck/Views/Sidebar/SidebarView.swift | 8 +- .../LocalizationLookupTests.swift | 13 ++ .../LocalizationResolverTests.swift | 25 +--- 20 files changed, 564 insertions(+), 175 deletions(-) diff --git a/Sources/SkillDeck/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SkillDeck/Resources/Localizations/en.lproj/Localizable.strings index 930b83e..caf9dc2 100644 --- a/Sources/SkillDeck/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SkillDeck/Resources/Localizations/en.lproj/Localizable.strings @@ -91,3 +91,123 @@ "dashboard.language.menuLabel" = "Language"; "dashboard.language.menuHelp" = "Switch app language"; + +"common.cancel" = "Cancel"; +"common.close" = "Close"; +"common.done" = "Done"; +"common.ok" = "OK"; +"common.sourcePath" = "Source: %@"; +"common.showInFinder" = "Show in Finder"; +"sidebar.globalSync" = "Global Sync"; +"sidebar.projects" = "Projects"; +"content.preparingGlobalSync" = "Preparing global sync manager"; +"content.preparingProjects" = "Preparing project manager"; +"projects.title" = "Projects"; +"projects.empty.title" = "No Projects Added"; +"projects.empty.description" = "After you add a project root, SkillDeck scans only its direct .agents/skills directory."; +"projects.add" = "Add Project"; +"projects.rescan" = "Rescan Projects"; +"projects.chooseRoot" = "Choose Project Root"; +"projects.remove" = "Remove Project"; +"projects.skillCount" = "%d skills"; +"projects.syncedToolsCount" = "%d/%d tools fully synced"; +"projects.select.title" = "Select a Project"; +"projects.select.description" = "Select a project in the middle list to view its skill sync status."; +"projects.rules.title" = "Shared Rules"; +"projects.rules.syncedCount" = "%d/%d synced"; +"projects.rules.edit" = "Edit shared rules"; +"projects.rules.syncAll" = "Sync All Rules"; +"projects.rules.remove" = "Remove Rule Links"; +"projects.rules.create" = "Create Shared Rules"; +"projects.skills.missing.title" = "Project Source Skills Not Found"; +"projects.skills.missing.description" = "Add skills containing SKILL.md under the project's .agents/skills, then sync the whole directory."; +"projects.skills.empty.title" = "No Project Skills"; +"projects.skills.empty.description" = "Add skills containing SKILL.md under the project's .agents/skills."; +"projects.skillDetail.project" = "Project skill. Click to view and update."; +"projects.skillDetail.global" = "Global source skill. Click to view and update."; +"projects.skillDetail.help" = "View and update project skill"; +"projects.sync.allTools" = "Sync All Tools"; +"projects.sync.directory" = "Sync Entire Skills Directory"; +"projects.sync.removeDirectory" = "Remove Directory Sync Link"; +"projects.sync.sourceSkillCount" = "%d source skills"; +"projects.change.title" = "Project Sync Changes"; +"projects.change.destination" = "Each target skills directory will point to: %@"; +"projects.change.containsSkills" = "Contains %d source skills"; +"projects.sync.failed" = "Sync Failed"; +"projects.sync.result" = "Sync Result"; +"projects.rules.change.title" = "Shared Rule Changes"; +"projects.rules.change.description" = "All target rules will reference the same AGENTS.md; Cursor uses a recognized .mdc wrapper rule."; +"projects.rules.failed" = "Rule Sync Failed"; +"projects.rules.result" = "Rule Sync Result"; +"globalSync.rescan" = "Rescan Global Skills"; +"projectDetail.update" = "Project Updates"; +"projectDetail.source" = "Source"; +"projectDetail.repository" = "Repository"; +"projectDetail.updatedAt" = "Updated"; +"projectDetail.updateFound" = "Update Available"; +"projectDetail.updateNow" = "Update"; +"projectDetail.checkUpdate" = "Check for Updates"; +"projectDetail.linkRepository" = "Link Repository"; +"projectDetail.readFailed" = "Unable to Read SKILL.md"; +"projectDetail.openTerminal" = "Open in Terminal"; +"projectDetail.openEditor" = "Open SKILL.md in Default Editor"; +"dashboard.scope" = "Skill Scope"; +"dashboard.project" = "Project"; +"dashboard.allProjects" = "All Projects"; +"dashboard.disabled.title" = "Disabled Skills"; +"dashboard.disabled.count" = "Disabled Skills (%d)"; +"dashboard.disabled.help" = "View and restore disabled skills"; +"dashboard.disable" = "Disable"; +"dashboard.disable.description" = "\"%@\" will be moved to ~/.agents/.skilldeck-disabled, not deleted. You can restore it from Disabled Skills."; +"dashboard.disabled.empty" = "No Disabled Skills"; +"dashboard.restore" = "Restore"; +"dashboard.scope.all" = "All"; +"dashboard.scope.global" = "Global"; +"dashboard.scope.project" = "Project"; + +"sync.state.linked" = "Synced"; +"sync.state.directoryLinked" = "Entire Directory Synced"; +"sync.state.missing" = "Not Synced"; +"sync.state.broken" = "Broken Symlink"; +"sync.state.foreignLink" = "Points Elsewhere"; +"sync.state.occupied" = "Existing File"; +"sync.state.rootConflict" = "Target Directory Conflict"; +"sync.change.createDirectory" = "Create Directory Symlink"; +"sync.change.replaceDirectory" = "Back Up and Replace Directory"; +"sync.change.removeDirectory" = "Remove Directory Symlink"; +"sync.summary.replaceDirectory" = "The target skills directory already has content. It will be backed up, then replaced with a link to the project's .agents/skills."; +"sync.summary.createDirectory" = "Create a symlink for the entire skills directory to the project's .agents/skills."; +"sync.summary.removeDirectory" = "Remove only the target directory symlink; project source skills are not deleted."; +"sync.result.keepDirectory" = "Keep target skills directory"; +"sync.result.backup" = "Backed up to %@"; +"sync.result.applied" = "Applied: %@"; +"rule.target.claudeDirectory" = "Claude Rules Directory"; +"rule.target.cursor" = "Cursor Project Rules"; +"rule.target.codex" = "Codex / Generic Agent"; +"rule.change.create" = "Create Rule Symlink"; +"rule.change.replace" = "Back Up and Replace Rule"; +"rule.change.remove" = "Remove Rule Symlink"; +"rule.summary.replace" = "An existing rule file or external symlink will be backed up and replaced with the shared rule."; +"rule.summary.cursorCreate" = "Create a Cursor rule file that references the shared AGENTS.md."; +"rule.summary.create" = "Create a rule symlink to the shared AGENTS.md."; +"rule.summary.remove" = "Remove only the SkillDeck-created rule symlink; the shared rule source is not deleted."; +"rule.result.keep" = "Keep existing rules for %@"; +"rule.result.backup" = "%@ backed up to %@"; +"rule.result.applied" = "Applied %@: %@"; +"sync.action.backupAll" = "Back Up and Sync %d Tools"; +"sync.action.all" = "Sync %d Tools"; +"sync.action.backupReplace" = "Back Up and Replace Directory"; +"sync.action.removeLink" = "Remove Directory Symlink"; +"sync.action.createLink" = "Create Directory Symlink"; +"rule.action.backup" = "Back Up and Sync %d Rules"; +"rule.action.remove" = "Remove %d Rule Links"; +"rule.action.sync" = "Sync %d Rules"; +"projectDetail.status.linkedAndSynced" = "Repository linked and current project skill synced"; +"projectDetail.status.updateAvailable" = "Update available"; +"projectDetail.status.upToDate" = "Already up to date"; +"projectDetail.status.updated" = "Project skill updated to the latest version"; +"projectDetail.error.linkRequired" = "Link a GitHub repository first"; +"projectDetail.error.notFound" = "Skill not found in repository: %@"; +"dashboard.error.disabledReadFailed" = "Failed to read disabled skills: %@"; +"dashboard.error.disableFailed" = "Failed to disable skill: %@"; +"dashboard.error.restoreConflict" = "Unable to restore; a skill already exists at the original location: %@"; diff --git a/Sources/SkillDeck/Resources/Localizations/zh-Hans.lproj/Localizable.strings b/Sources/SkillDeck/Resources/Localizations/zh-Hans.lproj/Localizable.strings index 123b514..2b3a7c8 100644 --- a/Sources/SkillDeck/Resources/Localizations/zh-Hans.lproj/Localizable.strings +++ b/Sources/SkillDeck/Resources/Localizations/zh-Hans.lproj/Localizable.strings @@ -91,3 +91,123 @@ "dashboard.language.menuLabel" = "语言"; "dashboard.language.menuHelp" = "切换应用语言"; + +"common.cancel" = "取消"; +"common.close" = "关闭"; +"common.done" = "完成"; +"common.ok" = "好"; +"common.sourcePath" = "源:%@"; +"common.showInFinder" = "在 Finder 中显示"; +"sidebar.globalSync" = "全局同步"; +"sidebar.projects" = "项目"; +"content.preparingGlobalSync" = "正在准备全局同步管理器"; +"content.preparingProjects" = "正在准备项目管理器"; +"projects.title" = "项目"; +"projects.empty.title" = "尚未添加项目"; +"projects.empty.description" = "添加一个项目根目录后,SkillDeck 只会扫描其直接下的 .agents/skills。"; +"projects.add" = "添加项目"; +"projects.rescan" = "重新扫描项目"; +"projects.chooseRoot" = "选择项目根目录"; +"projects.remove" = "移除项目"; +"projects.skillCount" = "%d 个技能"; +"projects.syncedToolsCount" = "%d/%d 个工具已完全同步"; +"projects.select.title" = "选择项目"; +"projects.select.description" = "在中间列表中选择一个项目,查看它的技能同步状态。"; +"projects.rules.title" = "统一规则"; +"projects.rules.syncedCount" = "%d/%d 已同步"; +"projects.rules.edit" = "编辑统一规则"; +"projects.rules.syncAll" = "同步全部规则"; +"projects.rules.remove" = "移除规则链接"; +"projects.rules.create" = "新建统一规则"; +"projects.skills.missing.title" = "未检测到项目源技能"; +"projects.skills.missing.description" = "在项目 .agents/skills 下添加包含 SKILL.md 的技能后即可整体同步。"; +"projects.skills.empty.title" = "没有项目技能"; +"projects.skills.empty.description" = "在项目 .agents/skills 下添加包含 SKILL.md 的技能。"; +"projects.skillDetail.project" = "项目技能,点击查看和更新"; +"projects.skillDetail.global" = "全局源技能,点击查看和更新"; +"projects.skillDetail.help" = "查看和更新项目技能"; +"projects.sync.allTools" = "同步全部工具"; +"projects.sync.directory" = "同步整个 skills 目录"; +"projects.sync.removeDirectory" = "移除同步目录链接"; +"projects.sync.sourceSkillCount" = "源技能 %d 项"; +"projects.change.title" = "项目同步变更"; +"projects.change.destination" = "同步会将每个目标 skills 目录指向:%@"; +"projects.change.containsSkills" = "包含 %d 个源技能"; +"projects.sync.failed" = "同步失败"; +"projects.sync.result" = "同步结果"; +"projects.rules.change.title" = "统一规则变更"; +"projects.rules.change.description" = "所有目标规则将引用同一份 AGENTS.md;Cursor 使用可识别的 .mdc 包装规则。"; +"projects.rules.failed" = "规则同步失败"; +"projects.rules.result" = "规则同步结果"; +"globalSync.rescan" = "重新扫描全局技能"; +"projectDetail.update" = "项目更新"; +"projectDetail.source" = "来源"; +"projectDetail.repository" = "仓库"; +"projectDetail.updatedAt" = "更新于"; +"projectDetail.updateFound" = "发现更新"; +"projectDetail.updateNow" = "更新"; +"projectDetail.checkUpdate" = "检查更新"; +"projectDetail.linkRepository" = "关联仓库"; +"projectDetail.readFailed" = "无法读取 SKILL.md"; +"projectDetail.openTerminal" = "在终端中打开"; +"projectDetail.openEditor" = "用默认编辑器打开 SKILL.md"; +"dashboard.scope" = "技能范围"; +"dashboard.project" = "项目"; +"dashboard.allProjects" = "全部项目"; +"dashboard.disabled.title" = "已禁用技能"; +"dashboard.disabled.count" = "已禁用技能(%d)"; +"dashboard.disabled.help" = "查看并恢复已禁用的技能"; +"dashboard.disable" = "禁用"; +"dashboard.disable.description" = "\"%@\" 会移至 ~/.agents/.skilldeck-disabled,不会删除;可从“已禁用技能”恢复。"; +"dashboard.disabled.empty" = "没有已禁用技能"; +"dashboard.restore" = "恢复"; +"dashboard.scope.all" = "全部"; +"dashboard.scope.global" = "全局"; +"dashboard.scope.project" = "项目"; + +"sync.state.linked" = "已同步"; +"sync.state.directoryLinked" = "整目录已同步"; +"sync.state.missing" = "未同步"; +"sync.state.broken" = "失效软链"; +"sync.state.foreignLink" = "指向其他位置"; +"sync.state.occupied" = "已有真实文件"; +"sync.state.rootConflict" = "目标目录冲突"; +"sync.change.createDirectory" = "创建目录软链"; +"sync.change.replaceDirectory" = "备份后整体替换"; +"sync.change.removeDirectory" = "移除目录软链"; +"sync.summary.replaceDirectory" = "目标 skills 目录已有内容。备份后将整个目录改为直接指向项目 .agents/skills。"; +"sync.summary.createDirectory" = "创建整个 skills 目录软链,直接指向项目 .agents/skills。"; +"sync.summary.removeDirectory" = "只移除目标目录软链,不删除项目源技能。"; +"sync.result.keepDirectory" = "保留目标 skills 目录"; +"sync.result.backup" = "已备份到 %@"; +"sync.result.applied" = "已执行 %@"; +"rule.target.claudeDirectory" = "Claude 规则目录"; +"rule.target.cursor" = "Cursor 项目规则"; +"rule.target.codex" = "Codex / 通用 Agent"; +"rule.change.create" = "创建规则软链"; +"rule.change.replace" = "备份后替换规则"; +"rule.change.remove" = "移除规则软链"; +"rule.summary.replace" = "已有规则文件或外部软链,备份后替换为统一规则。"; +"rule.summary.cursorCreate" = "创建 Cursor 规则文件,并引用统一的 AGENTS.md。"; +"rule.summary.create" = "创建规则软链,指向统一的 AGENTS.md。"; +"rule.summary.remove" = "只移除由 SkillDeck 创建的规则软链,不删除统一规则源。"; +"rule.result.keep" = "保留 %@ 的现有规则"; +"rule.result.backup" = "%@ 已备份到 %@"; +"rule.result.applied" = "已执行 %@:%@"; +"sync.action.backupAll" = "备份后同步 %d 个工具"; +"sync.action.all" = "同步 %d 个工具"; +"sync.action.backupReplace" = "备份后整体替换"; +"sync.action.removeLink" = "移除目录软链"; +"sync.action.createLink" = "创建目录软链"; +"rule.action.backup" = "备份后同步 %d 条规则"; +"rule.action.remove" = "移除 %d 条规则链接"; +"rule.action.sync" = "同步 %d 条规则"; +"projectDetail.status.linkedAndSynced" = "已关联仓库,并已同步当前项目技能"; +"projectDetail.status.updateAvailable" = "发现可用更新"; +"projectDetail.status.upToDate" = "当前已是最新版本"; +"projectDetail.status.updated" = "项目技能已更新到最新版本"; +"projectDetail.error.linkRequired" = "请先关联 GitHub 仓库"; +"projectDetail.error.notFound" = "仓库中未找到技能:%@"; +"dashboard.error.disabledReadFailed" = "读取已禁用技能失败:%@"; +"dashboard.error.disableFailed" = "禁用技能失败:%@"; +"dashboard.error.restoreConflict" = "无法恢复,原位置已有技能:%@"; diff --git a/Sources/SkillDeck/Services/DisabledSkillStore.swift b/Sources/SkillDeck/Services/DisabledSkillStore.swift index 4a49ea8..ca2ffd4 100644 --- a/Sources/SkillDeck/Services/DisabledSkillStore.swift +++ b/Sources/SkillDeck/Services/DisabledSkillStore.swift @@ -98,7 +98,7 @@ enum DisabledSkillStoreError: LocalizedError { var errorDescription: String? { switch self { case .restoreDestinationExists(let path): - return "无法恢复,原位置已有技能:\(path)" + return L10n.currentFormat(L10nKeys.dashboardRestoreConflict, path) } } } diff --git a/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift b/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift index d7d31c7..29de64a 100644 --- a/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift +++ b/Sources/SkillDeck/Services/ProjectSkillUpdateService.swift @@ -133,9 +133,9 @@ enum ProjectSkillUpdateError: LocalizedError { var errorDescription: String? { switch self { case .repositoryNotLinked: - return "请先关联 GitHub 仓库" + return L10n.currentString(L10nKeys.projectDetailLinkRequired) case .skillNotFoundInRepository(let name): - return "仓库中未找到技能:\(name)" + return L10n.currentFormat(L10nKeys.projectDetailNotFound, name) } } } diff --git a/Sources/SkillDeck/Services/RuleSyncFileSystem.swift b/Sources/SkillDeck/Services/RuleSyncFileSystem.swift index b361924..2a2dd4f 100644 --- a/Sources/SkillDeck/Services/RuleSyncFileSystem.swift +++ b/Sources/SkillDeck/Services/RuleSyncFileSystem.swift @@ -12,7 +12,18 @@ enum RuleTargetFormat: Equatable { /// global rules live in each tool's user configuration directory. struct RuleSyncTarget: Identifiable, Hashable { let id: String - let name: String + private let defaultName: String + + /// Target display names are computed so they refresh with the app language instead of + /// being frozen when the static target registry is first initialized. + var name: String { + switch id { + case "codex": L10n.currentString(L10nKeys.ruleTargetCodex) + case "claude-rules": L10n.currentString(L10nKeys.ruleTargetClaudeDirectory) + case "cursor": L10n.currentString(L10nKeys.ruleTargetCursor) + default: defaultName + } + } let projectRelativePath: String let globalRelativePath: String let format: RuleTargetFormat @@ -25,7 +36,7 @@ struct RuleSyncTarget: Identifiable, Hashable { format: RuleTargetFormat = .symbolicLink ) { self.id = id - self.name = name + self.defaultName = name self.projectRelativePath = projectRelativePath self.globalRelativePath = globalRelativePath self.format = format @@ -41,13 +52,13 @@ struct RuleSyncTarget: Identifiable, Hashable { } static let all: [RuleSyncTarget] = [ - .init(id: "codex", name: "Codex / 通用 Agent", projectRelativePath: "AGENTS.md", globalRelativePath: ".codex/AGENTS.md"), + .init(id: "codex", name: "Codex / Generic Agent", projectRelativePath: "AGENTS.md", globalRelativePath: ".codex/AGENTS.md"), .init(id: "claude", name: "Claude Code", projectRelativePath: "CLAUDE.md", globalRelativePath: ".claude/CLAUDE.md"), .init(id: "gemini", name: "Gemini CLI", projectRelativePath: "GEMINI.md", globalRelativePath: ".gemini/GEMINI.md"), - .init(id: "claude-rules", name: "Claude 规则目录", projectRelativePath: ".claude/rules/skilldeck.md", globalRelativePath: ".claude/rules/skilldeck.md"), + .init(id: "claude-rules", name: "Claude Rules Directory", projectRelativePath: ".claude/rules/skilldeck.md", globalRelativePath: ".claude/rules/skilldeck.md"), .init(id: "copilot", name: "GitHub Copilot", projectRelativePath: ".github/copilot-instructions.md", globalRelativePath: ""), .init(id: "kiro", name: "Kiro Steering", projectRelativePath: ".kiro/steering/skilldeck.md", globalRelativePath: ".kiro/steering/skilldeck.md"), - .init(id: "cursor", name: "Cursor 项目规则", projectRelativePath: ".cursor/rules/skilldeck.mdc", globalRelativePath: "", format: .cursorRule) + .init(id: "cursor", name: "Cursor Project Rules", projectRelativePath: ".cursor/rules/skilldeck.mdc", globalRelativePath: "", format: .cursorRule) ] } @@ -56,11 +67,11 @@ enum RuleLinkState: String { var label: String { switch self { - case .linked: "已同步" - case .missing: "未同步" - case .broken: "失效软链" - case .foreignLink: "指向其他位置" - case .occupied: "已有规则文件" + case .linked: L10n.currentString(L10nKeys.syncStateLinked) + case .missing: L10n.currentString(L10nKeys.syncStateMissing) + case .broken: L10n.currentString(L10nKeys.syncStateBroken) + case .foreignLink: L10n.currentString(L10nKeys.syncStateForeignLink) + case .occupied: L10n.currentString(L10nKeys.syncStateOccupied) } } } @@ -80,9 +91,9 @@ enum RuleChangeKind: String { var label: String { switch self { - case .createLink: "创建规则软链" - case .replaceItem: "备份后替换规则" - case .removeLink: "移除规则软链" + case .createLink: L10n.currentString(L10nKeys.ruleChangeCreate) + case .replaceItem: L10n.currentString(L10nKeys.ruleChangeReplace) + case .removeLink: L10n.currentString(L10nKeys.ruleChangeRemove) } } } @@ -167,7 +178,7 @@ struct RuleSyncFileSystem { target: inspection.target, sourceURL: inspection.sourceURL, targetURL: inspection.targetURL, - summary: "已有规则文件或外部软链,备份后替换为统一规则。", + summary: L10n.currentString(L10nKeys.ruleSummaryReplace), needsResolution: true ) } @@ -177,8 +188,8 @@ struct RuleSyncFileSystem { sourceURL: inspection.sourceURL, targetURL: inspection.targetURL, summary: inspection.target.format == .cursorRule - ? "创建 Cursor 规则文件,并引用统一的 AGENTS.md。" - : "创建规则软链,指向统一的 AGENTS.md。", + ? L10n.currentString(L10nKeys.ruleSummaryCursorCreate) + : L10n.currentString(L10nKeys.ruleSummaryCreate), needsResolution: false ) } @@ -192,7 +203,7 @@ struct RuleSyncFileSystem { target: inspection.target, sourceURL: inspection.sourceURL, targetURL: inspection.targetURL, - summary: "只移除由 SkillDeck 创建的规则软链,不删除统一规则源。", + summary: L10n.currentString(L10nKeys.ruleSummaryRemove), needsResolution: false ) } @@ -204,15 +215,15 @@ struct RuleSyncFileSystem { try fileManager.createDirectory(at: change.targetURL.deletingLastPathComponent(), withIntermediateDirectories: true) try writeTarget(for: change) case .replaceItem: - guard change.resolution == .backupAndReplace else { return "保留 \(change.target.name) 的现有规则" } + guard change.resolution == .backupAndReplace else { return L10n.currentFormat(L10nKeys.ruleResultKeep, change.target.name) } let backup = try backup(change.targetURL) try fileManager.createDirectory(at: change.targetURL.deletingLastPathComponent(), withIntermediateDirectories: true) try writeTarget(for: change) - return "\(change.target.name) 已备份到 \(backup.path)" + return L10n.currentFormat(L10nKeys.ruleResultBackup, change.target.name, backup.path) case .removeLink: try fileManager.removeItem(at: change.targetURL) } - return "已执行 \(change.kind.label):\(change.target.name)" + return L10n.currentFormat(L10nKeys.ruleResultApplied, change.kind.label, change.target.name) } private func linkDestination(at url: URL) -> URL? { diff --git a/Sources/SkillDeck/Utilities/Localization/L10n.swift b/Sources/SkillDeck/Utilities/Localization/L10n.swift index e27e629..d91b2c0 100644 --- a/Sources/SkillDeck/Utilities/Localization/L10n.swift +++ b/Sources/SkillDeck/Utilities/Localization/L10n.swift @@ -12,6 +12,26 @@ import Foundation /// - This is an `enum` with only `static` members, used as a namespacing pattern. /// Swift does not have packages in the Java sense, so this is a common way to group helpers. enum L10n { + /// Looks up a string using the app language preference outside SwiftUI views. + /// + /// Services cannot read SwiftUI's environment values, but sync plans and operation results + /// are still user-facing. Resolving the same preference here keeps those messages bilingual + /// without coupling filesystem code to SwiftUI. + static func currentString(_ key: String) -> String { + let rawLanguage = UserDefaults.standard.string(forKey: LanguageSettings.appLanguageKey) + let language = AppLanguage(rawValue: rawLanguage ?? LanguageSettings.defaultLanguage.rawValue) ?? LanguageSettings.defaultLanguage + let resolution = LocalizationResolver.resolve(language: language) + return string(key, bundle: resolution.bundle, locale: resolution.locale) + } + + /// Formats a localized template with values while retaining the caller's active language. + static func currentFormat(_ key: String, _ arguments: CVarArg...) -> String { + let rawLanguage = UserDefaults.standard.string(forKey: LanguageSettings.appLanguageKey) + let language = AppLanguage(rawValue: rawLanguage ?? LanguageSettings.defaultLanguage.rawValue) ?? LanguageSettings.defaultLanguage + let resolution = LocalizationResolver.resolve(language: language) + return String(format: string(key, bundle: resolution.bundle, locale: resolution.locale), locale: resolution.locale, arguments: arguments) + } + static func string( _ key: String, bundle: Bundle, diff --git a/Sources/SkillDeck/Utilities/Localization/L10nKeys.swift b/Sources/SkillDeck/Utilities/Localization/L10nKeys.swift index e4005e4..0209ae5 100644 --- a/Sources/SkillDeck/Utilities/Localization/L10nKeys.swift +++ b/Sources/SkillDeck/Utilities/Localization/L10nKeys.swift @@ -90,6 +90,125 @@ enum L10nKeys { static let dashboardLanguageMenuLabel = "dashboard.language.menuLabel" static let dashboardLanguageMenuHelp = "dashboard.language.menuHelp" + static let commonCancel = "common.cancel" + static let commonClose = "common.close" + static let commonDone = "common.done" + static let commonOK = "common.ok" + static let commonSourcePath = "common.sourcePath" + static let commonShowInFinder = "common.showInFinder" + static let sidebarGlobalSync = "sidebar.globalSync" + static let sidebarProjects = "sidebar.projects" + static let contentPreparingGlobalSync = "content.preparingGlobalSync" + static let contentPreparingProjects = "content.preparingProjects" + static let projectsTitle = "projects.title" + static let projectsEmptyTitle = "projects.empty.title" + static let projectsEmptyDescription = "projects.empty.description" + static let projectsAdd = "projects.add" + static let projectsRescan = "projects.rescan" + static let projectsChooseRoot = "projects.chooseRoot" + static let projectsRemove = "projects.remove" + static let projectsSkillCount = "projects.skillCount" + static let projectsSyncedToolsCount = "projects.syncedToolsCount" + static let projectsSelectTitle = "projects.select.title" + static let projectsSelectDescription = "projects.select.description" + static let projectsRulesTitle = "projects.rules.title" + static let projectsRulesSyncedCount = "projects.rules.syncedCount" + static let projectsRulesEdit = "projects.rules.edit" + static let projectsRulesSyncAll = "projects.rules.syncAll" + static let projectsRulesRemove = "projects.rules.remove" + static let projectsRulesCreate = "projects.rules.create" + static let projectsSkillsMissingTitle = "projects.skills.missing.title" + static let projectsSkillsMissingDescription = "projects.skills.missing.description" + static let projectsSkillsEmptyTitle = "projects.skills.empty.title" + static let projectsSkillsEmptyDescription = "projects.skills.empty.description" + static let projectsSkillDetailProject = "projects.skillDetail.project" + static let projectsSkillDetailGlobal = "projects.skillDetail.global" + static let projectsSkillDetailHelp = "projects.skillDetail.help" + static let projectsSyncAllTools = "projects.sync.allTools" + static let projectsSyncDirectory = "projects.sync.directory" + static let projectsRemoveDirectory = "projects.sync.removeDirectory" + static let projectsSourceSkillCount = "projects.sync.sourceSkillCount" + static let projectsChangeTitle = "projects.change.title" + static let projectsChangeDestination = "projects.change.destination" + static let projectsChangeContainsSkills = "projects.change.containsSkills" + static let projectsSyncFailed = "projects.sync.failed" + static let projectsSyncResult = "projects.sync.result" + static let projectsRulesChangeTitle = "projects.rules.change.title" + static let projectsRulesChangeDescription = "projects.rules.change.description" + static let projectsRulesFailed = "projects.rules.failed" + static let projectsRulesResult = "projects.rules.result" + static let globalSyncRescan = "globalSync.rescan" + static let projectDetailUpdate = "projectDetail.update" + static let projectDetailSource = "projectDetail.source" + static let projectDetailRepository = "projectDetail.repository" + static let projectDetailUpdatedAt = "projectDetail.updatedAt" + static let projectDetailUpdateFound = "projectDetail.updateFound" + static let projectDetailUpdateNow = "projectDetail.updateNow" + static let projectDetailCheckUpdate = "projectDetail.checkUpdate" + static let projectDetailLinkRepository = "projectDetail.linkRepository" + static let projectDetailReadFailed = "projectDetail.readFailed" + static let projectDetailOpenTerminal = "projectDetail.openTerminal" + static let projectDetailOpenEditor = "projectDetail.openEditor" + static let dashboardScope = "dashboard.scope" + static let dashboardProject = "dashboard.project" + static let dashboardAllProjects = "dashboard.allProjects" + static let dashboardDisabledTitle = "dashboard.disabled.title" + static let dashboardDisabledCount = "dashboard.disabled.count" + static let dashboardDisabledHelp = "dashboard.disabled.help" + static let dashboardDisable = "dashboard.disable" + static let dashboardDisableDescription = "dashboard.disable.description" + static let dashboardDisabledEmpty = "dashboard.disabled.empty" + static let dashboardRestore = "dashboard.restore" + static let dashboardAll = "dashboard.scope.all" + static let dashboardGlobal = "dashboard.scope.global" + static let dashboardProjectScope = "dashboard.scope.project" + static let syncStateLinked = "sync.state.linked" + static let syncStateDirectoryLinked = "sync.state.directoryLinked" + static let syncStateMissing = "sync.state.missing" + static let syncStateBroken = "sync.state.broken" + static let syncStateForeignLink = "sync.state.foreignLink" + static let syncStateOccupied = "sync.state.occupied" + static let syncStateRootConflict = "sync.state.rootConflict" + static let syncChangeCreateDirectory = "sync.change.createDirectory" + static let syncChangeReplaceDirectory = "sync.change.replaceDirectory" + static let syncChangeRemoveDirectory = "sync.change.removeDirectory" + static let syncSummaryReplaceDirectory = "sync.summary.replaceDirectory" + static let syncSummaryCreateDirectory = "sync.summary.createDirectory" + static let syncSummaryRemoveDirectory = "sync.summary.removeDirectory" + static let syncResultKeepDirectory = "sync.result.keepDirectory" + static let syncResultBackup = "sync.result.backup" + static let syncResultApplied = "sync.result.applied" + static let ruleTargetClaudeDirectory = "rule.target.claudeDirectory" + static let ruleTargetCursor = "rule.target.cursor" + static let ruleTargetCodex = "rule.target.codex" + static let ruleChangeCreate = "rule.change.create" + static let ruleChangeReplace = "rule.change.replace" + static let ruleChangeRemove = "rule.change.remove" + static let ruleSummaryReplace = "rule.summary.replace" + static let ruleSummaryCursorCreate = "rule.summary.cursorCreate" + static let ruleSummaryCreate = "rule.summary.create" + static let ruleSummaryRemove = "rule.summary.remove" + static let ruleResultKeep = "rule.result.keep" + static let ruleResultBackup = "rule.result.backup" + static let ruleResultApplied = "rule.result.applied" + static let syncActionBackupAll = "sync.action.backupAll" + static let syncActionAll = "sync.action.all" + static let syncActionBackupReplace = "sync.action.backupReplace" + static let syncActionRemoveLink = "sync.action.removeLink" + static let syncActionCreateLink = "sync.action.createLink" + static let ruleActionBackup = "rule.action.backup" + static let ruleActionRemove = "rule.action.remove" + static let ruleActionSync = "rule.action.sync" + static let projectDetailLinkedAndSynced = "projectDetail.status.linkedAndSynced" + static let projectDetailUpdateAvailable = "projectDetail.status.updateAvailable" + static let projectDetailUpToDate = "projectDetail.status.upToDate" + static let projectDetailUpdated = "projectDetail.status.updated" + static let projectDetailLinkRequired = "projectDetail.error.linkRequired" + static let projectDetailNotFound = "projectDetail.error.notFound" + static let dashboardDisabledReadFailed = "dashboard.error.disabledReadFailed" + static let dashboardDisableFailed = "dashboard.error.disableFailed" + static let dashboardRestoreConflict = "dashboard.error.restoreConflict" + static let allKeys: [String] = [ appName, settingsTabGeneral, @@ -163,5 +282,13 @@ enum L10nKeys { emptySelectSkillSubtitleClawHub, dashboardLanguageMenuLabel, dashboardLanguageMenuHelp, + commonCancel, commonClose, commonDone, commonOK, commonSourcePath, commonShowInFinder, + sidebarGlobalSync, sidebarProjects, contentPreparingGlobalSync, contentPreparingProjects, + projectsTitle, projectsEmptyTitle, projectsEmptyDescription, projectsAdd, projectsRescan, projectsChooseRoot, projectsRemove, projectsSkillCount, projectsSyncedToolsCount, projectsSelectTitle, projectsSelectDescription, projectsRulesTitle, projectsRulesSyncedCount, projectsRulesEdit, projectsRulesSyncAll, projectsRulesRemove, projectsRulesCreate, projectsSkillsMissingTitle, projectsSkillsMissingDescription, projectsSkillsEmptyTitle, projectsSkillsEmptyDescription, projectsSkillDetailProject, projectsSkillDetailGlobal, projectsSkillDetailHelp, projectsSyncAllTools, projectsSyncDirectory, projectsRemoveDirectory, projectsSourceSkillCount, projectsChangeTitle, projectsChangeDestination, projectsChangeContainsSkills, projectsSyncFailed, projectsSyncResult, projectsRulesChangeTitle, projectsRulesChangeDescription, projectsRulesFailed, projectsRulesResult, + globalSyncRescan, projectDetailUpdate, projectDetailSource, projectDetailRepository, projectDetailUpdatedAt, projectDetailUpdateFound, projectDetailUpdateNow, projectDetailCheckUpdate, projectDetailLinkRepository, projectDetailReadFailed, projectDetailOpenTerminal, projectDetailOpenEditor, + dashboardScope, dashboardProject, dashboardAllProjects, dashboardDisabledTitle, dashboardDisabledCount, dashboardDisabledHelp, dashboardDisable, dashboardDisableDescription, dashboardDisabledEmpty, dashboardRestore, dashboardAll, dashboardGlobal, dashboardProjectScope, + syncStateLinked, syncStateDirectoryLinked, syncStateMissing, syncStateBroken, syncStateForeignLink, syncStateOccupied, syncStateRootConflict, syncChangeCreateDirectory, syncChangeReplaceDirectory, syncChangeRemoveDirectory, syncSummaryReplaceDirectory, syncSummaryCreateDirectory, syncSummaryRemoveDirectory, syncResultKeepDirectory, syncResultBackup, syncResultApplied, ruleTargetClaudeDirectory, ruleTargetCursor, ruleTargetCodex, ruleChangeCreate, ruleChangeReplace, ruleChangeRemove, ruleSummaryReplace, ruleSummaryCursorCreate, ruleSummaryCreate, ruleSummaryRemove, ruleResultKeep, ruleResultBackup, ruleResultApplied, + syncActionBackupAll, syncActionAll, syncActionBackupReplace, syncActionRemoveLink, syncActionCreateLink, ruleActionBackup, ruleActionRemove, ruleActionSync, + projectDetailLinkedAndSynced, projectDetailUpdateAvailable, projectDetailUpToDate, projectDetailUpdated, projectDetailLinkRequired, projectDetailNotFound, dashboardDisabledReadFailed, dashboardDisableFailed, dashboardRestoreConflict, ] } diff --git a/Sources/SkillDeck/Utilities/Localization/LocalizationResolver.swift b/Sources/SkillDeck/Utilities/Localization/LocalizationResolver.swift index 61df134..196a053 100644 --- a/Sources/SkillDeck/Utilities/Localization/LocalizationResolver.swift +++ b/Sources/SkillDeck/Utilities/Localization/LocalizationResolver.swift @@ -25,8 +25,7 @@ enum LocalizationResolver { /// - `bundle` is the provided `baseBundle` /// - explicit language (e.g. `en`, `zh-Hans`): /// - `locale` is `Locale(identifier: rawValue)` (deterministic override) - /// - `bundle` is a language-specific `.lproj` bundle if present under `baseBundle`, - /// otherwise it falls back to `baseBundle` + /// - `bundle` remains `baseBundle`; `L10n.string` selects the matching `.lproj` inside it /// /// - Parameters: /// - language: User preference. @@ -46,19 +45,9 @@ enum LocalizationResolver { case .english, .simplifiedChinese: // Create a deterministic locale override. let locale = Locale(identifier: language.rawValue) - - // Swift/iOS/macOS store localized resources under language folders: - // /.lproj/... - // If the `.lproj` exists we create a sub-bundle rooted at that directory. - // Otherwise we must safely fall back to the base bundle. - let candidates = [language.rawValue, language.rawValue.lowercased()] - for candidate in candidates { - if let lprojPath = baseBundle.path(forResource: candidate, ofType: "lproj"), - let languageBundle = Bundle(path: lprojPath) { - return Resolution(locale: locale, bundle: languageBundle) - } - } - + // Keep the resource-root bundle in the SwiftUI environment. Passing an `.lproj` + // sub-bundle back into `L10n.string` makes a second language lookup depend on the + // system locale, so English overrides can incorrectly return Chinese resources. return Resolution(locale: locale, bundle: baseBundle) } } diff --git a/Sources/SkillDeck/ViewModels/DashboardViewModel.swift b/Sources/SkillDeck/ViewModels/DashboardViewModel.swift index 103f73f..4f603d2 100644 --- a/Sources/SkillDeck/ViewModels/DashboardViewModel.swift +++ b/Sources/SkillDeck/ViewModels/DashboardViewModel.swift @@ -27,9 +27,9 @@ enum DashboardScopeFilter: CaseIterable, Identifiable { var title: String { switch self { - case .all: "全部" - case .global: "全局" - case .project: "项目" + case .all: L10n.currentString(L10nKeys.dashboardAll) + case .global: L10n.currentString(L10nKeys.dashboardGlobal) + case .project: L10n.currentString(L10nKeys.dashboardProjectScope) } } @@ -163,7 +163,7 @@ final class DashboardViewModel { do { disabledSkills = try DisabledSkillStore().records() } catch { - skillManager.errorMessage = "读取已禁用技能失败:\(error.localizedDescription)" + skillManager.errorMessage = L10n.currentFormat(L10nKeys.dashboardDisabledReadFailed, error.localizedDescription) } } @@ -267,7 +267,7 @@ final class DashboardViewModel { } reloadDisabledSkills() } catch { - skillManager.errorMessage = "禁用技能失败:\(error.localizedDescription)" + skillManager.errorMessage = L10n.currentFormat(L10nKeys.dashboardDisableFailed, error.localizedDescription) } itemToDisable = nil showDisableConfirmation = false diff --git a/Sources/SkillDeck/ViewModels/ProjectManager.swift b/Sources/SkillDeck/ViewModels/ProjectManager.swift index 0b5fe2a..1a2ab63 100644 --- a/Sources/SkillDeck/ViewModels/ProjectManager.swift +++ b/Sources/SkillDeck/ViewModels/ProjectManager.swift @@ -16,7 +16,13 @@ struct ManagedProject: Codable, Hashable, Identifiable { var id: String { rootPath } var rootURL: URL { URL(fileURLWithPath: rootPath) } - var name: String { displayName ?? rootURL.lastPathComponent } + /// The global source is a virtual project used only by the sync UI, so its display name + /// follows the current app language instead of exposing the user's home-folder name. + var name: String { + usesConfiguredGlobalSource == true + ? L10n.currentString(L10nKeys.sidebarGlobalSync) + : displayName ?? rootURL.lastPathComponent + } var sourceSkillsURL: URL { usesConfiguredGlobalSource == true ? SkillStorageSettings.globalSkillsURL : rootURL.appendingPathComponent(".agents/skills") } @@ -82,13 +88,13 @@ enum ProjectSkillState: String { var label: String { switch self { - case .linked: "已同步" - case .directoryLinked: "整目录已同步" - case .missing: "未同步" - case .broken: "失效软链" - case .foreignLink: "指向其他位置" - case .occupied: "已有真实文件" - case .rootConflict: "目标目录冲突" + case .linked: L10n.currentString(L10nKeys.syncStateLinked) + case .directoryLinked: L10n.currentString(L10nKeys.syncStateDirectoryLinked) + case .missing: L10n.currentString(L10nKeys.syncStateMissing) + case .broken: L10n.currentString(L10nKeys.syncStateBroken) + case .foreignLink: L10n.currentString(L10nKeys.syncStateForeignLink) + case .occupied: L10n.currentString(L10nKeys.syncStateOccupied) + case .rootConflict: L10n.currentString(L10nKeys.syncStateRootConflict) } } @@ -134,9 +140,9 @@ enum ProjectChangeKind: String { case createDirectoryLink, replaceDirectory, removeDirectoryLink var label: String { switch self { - case .createDirectoryLink: "创建目录软链" - case .replaceDirectory: "备份后整体替换" - case .removeDirectoryLink: "移除目录软链" + case .createDirectoryLink: L10n.currentString(L10nKeys.syncChangeCreateDirectory) + case .replaceDirectory: L10n.currentString(L10nKeys.syncChangeReplaceDirectory) + case .removeDirectoryLink: L10n.currentString(L10nKeys.syncChangeRemoveDirectory) } } } @@ -215,9 +221,9 @@ struct ProjectSyncFileSystem { let skillNames = inspection.skills.map(\.name) if sameLocation(linkDestination(at: target) ?? target, source) { return [] } if fileManager.fileExists(atPath: target.path) || linkDestination(at: target) != nil { - return [.init(kind: .replaceDirectory, sourceRoot: source, targetRoot: target, skillNames: skillNames, summary: "目标 skills 目录已有内容。备份后将整个目录改为直接指向项目 .agents/skills。", needsResolution: true)] + return [.init(kind: .replaceDirectory, sourceRoot: source, targetRoot: target, skillNames: skillNames, summary: L10n.currentString(L10nKeys.syncSummaryReplaceDirectory), needsResolution: true)] } - return [.init(kind: .createDirectoryLink, sourceRoot: source, targetRoot: target, skillNames: skillNames, summary: "创建整个 skills 目录软链,直接指向项目 .agents/skills。", needsResolution: false)] + return [.init(kind: .createDirectoryLink, sourceRoot: source, targetRoot: target, skillNames: skillNames, summary: L10n.currentString(L10nKeys.syncSummaryCreateDirectory), needsResolution: false)] } /// Removal is only available for the full directory link owned by project sync. @@ -226,7 +232,7 @@ struct ProjectSyncFileSystem { sameLocation(linkDestination(at: inspection.targetURL) ?? inspection.targetURL, inspection.project.sourceSkillsURL) else { return [] } - return [.init(kind: .removeDirectoryLink, sourceRoot: inspection.project.sourceSkillsURL, targetRoot: inspection.targetURL, skillNames: inspection.skills.map(\.name), summary: "只移除目标目录软链,不删除项目源技能。", needsResolution: false)] + return [.init(kind: .removeDirectoryLink, sourceRoot: inspection.project.sourceSkillsURL, targetRoot: inspection.targetURL, skillNames: inspection.skills.map(\.name), summary: L10n.currentString(L10nKeys.syncSummaryRemoveDirectory), needsResolution: false)] } func apply(_ change: ProjectChange) throws -> String { @@ -236,15 +242,15 @@ struct ProjectSyncFileSystem { try fileManager.createDirectory(at: change.targetRoot.deletingLastPathComponent(), withIntermediateDirectories: true) try fileManager.createSymbolicLink(at: change.targetRoot, withDestinationURL: change.sourceRoot) case .replaceDirectory: - guard change.resolution == .backupAndReplace else { return "保留目标 skills 目录" } + guard change.resolution == .backupAndReplace else { return L10n.currentString(L10nKeys.syncResultKeepDirectory) } let backup = try backup(change.targetRoot) try fileManager.createDirectory(at: change.targetRoot.deletingLastPathComponent(), withIntermediateDirectories: true) try fileManager.createSymbolicLink(at: change.targetRoot, withDestinationURL: change.sourceRoot) - return "已备份到 \(backup.path)" + return L10n.currentFormat(L10nKeys.syncResultBackup, backup.path) case .removeDirectoryLink: try fileManager.removeItem(at: change.targetRoot) } - return "已执行 \(change.kind.label)" + return L10n.currentFormat(L10nKeys.syncResultApplied, change.kind.label) } private func linkDestination(at url: URL) -> URL? { diff --git a/Sources/SkillDeck/ViewModels/ProjectSkillDetailViewModel.swift b/Sources/SkillDeck/ViewModels/ProjectSkillDetailViewModel.swift index d627c4c..f309aa0 100644 --- a/Sources/SkillDeck/ViewModels/ProjectSkillDetailViewModel.swift +++ b/Sources/SkillDeck/ViewModels/ProjectSkillDetailViewModel.swift @@ -48,7 +48,7 @@ final class ProjectSkillDetailViewModel { remoteHash = nil hasUpdate = false errorMessage = nil - statusMessage = "已关联仓库,并已同步当前项目技能" + statusMessage = L10n.currentString(L10nKeys.projectDetailLinkedAndSynced) } catch { errorMessage = error.localizedDescription statusMessage = nil @@ -64,7 +64,7 @@ final class ProjectSkillDetailViewModel { remoteHash = result.remoteHash hasUpdate = result.hasUpdate errorMessage = nil - statusMessage = result.hasUpdate ? "发现可用更新" : "当前已是最新版本" + statusMessage = L10n.currentString(result.hasUpdate ? L10nKeys.projectDetailUpdateAvailable : L10nKeys.projectDetailUpToDate) } catch { errorMessage = error.localizedDescription statusMessage = nil @@ -80,7 +80,7 @@ final class ProjectSkillDetailViewModel { self.remoteHash = nil hasUpdate = false errorMessage = nil - statusMessage = "项目技能已更新到最新版本" + statusMessage = L10n.currentString(L10nKeys.projectDetailUpdated) } catch { errorMessage = error.localizedDescription statusMessage = nil diff --git a/Sources/SkillDeck/Views/ContentView.swift b/Sources/SkillDeck/Views/ContentView.swift index 040903b..ceeaba8 100644 --- a/Sources/SkillDeck/Views/ContentView.swift +++ b/Sources/SkillDeck/Views/ContentView.swift @@ -92,13 +92,13 @@ struct ContentView: View { if let globalSyncManager { ProjectSyncDetail(manager: globalSyncManager) } else { - EmptyStateView(icon: "link", title: "全局同步", subtitle: "正在准备全局同步管理器") + EmptyStateView(icon: "link", title: L10n.string(L10nKeys.sidebarGlobalSync, bundle: localizationBundle, locale: locale), subtitle: L10n.string(L10nKeys.contentPreparingGlobalSync, bundle: localizationBundle, locale: locale)) } } else if selectedSidebarItem == .projects { if let projectManager { ProjectSyncDetail(manager: projectManager) } else { - EmptyStateView(icon: "folder", title: "项目", subtitle: "正在准备项目管理器") + EmptyStateView(icon: "folder", title: L10n.string(L10nKeys.sidebarProjects, bundle: localizationBundle, locale: locale), subtitle: L10n.string(L10nKeys.contentPreparingProjects, bundle: localizationBundle, locale: locale)) } } else if selectedSidebarItem == .registry { // F09: Show registry skill detail when a registry skill is selected @@ -164,7 +164,7 @@ struct ContentView: View { projectManager = manager dashboardVM = DashboardViewModel(skillManager: skillManager, projectManager: manager) detailVM = SkillDetailViewModel(skillManager: skillManager) - let globalManager = ProjectManager(fixedProject: ManagedProject(rootPath: NSHomeDirectory(), displayName: "全局技能", usesConfiguredGlobalSource: true)) + let globalManager = ProjectManager(fixedProject: ManagedProject(rootPath: NSHomeDirectory(), usesConfiguredGlobalSource: true)) globalManager.reload() globalSyncManager = globalManager // F09: Initialize registry browser ViewModel diff --git a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift index 4a21d79..9d29ef3 100644 --- a/Sources/SkillDeck/Views/Dashboard/DashboardView.swift +++ b/Sources/SkillDeck/Views/Dashboard/DashboardView.swift @@ -25,7 +25,7 @@ struct DashboardView: View { Button { viewModel.showsDisabledSkills = true } label: { - Label("已禁用技能(\(viewModel.disabledSkills.count))", systemImage: "archivebox") + Label(L10n.currentFormat(L10nKeys.dashboardDisabledCount, viewModel.disabledSkills.count), systemImage: "archivebox") } .buttonStyle(.bordered) Spacer() @@ -36,7 +36,7 @@ struct DashboardView: View { } // Segmented controls are the native compact control for mutually exclusive display modes. - Picker("技能范围", selection: $viewModel.scopeFilter) { + Picker(L10n.currentString(L10nKeys.dashboardScope), selection: $viewModel.scopeFilter) { ForEach(DashboardScopeFilter.allCases) { filter in Text(filter.title).tag(filter) } @@ -47,8 +47,8 @@ struct DashboardView: View { .padding(.vertical, 8) if viewModel.scopeFilter == .project { - Picker("项目", selection: $viewModel.selectedProjectFilterID) { - Text("全部项目").tag(String?.none) + Picker(L10n.currentString(L10nKeys.dashboardProject), selection: $viewModel.selectedProjectFilterID) { + Text(L10n.currentString(L10nKeys.dashboardAllProjects)).tag(String?.none) ForEach(viewModel.projectManager.projects) { project in Text(project.name).tag(Optional(project.id)) } @@ -87,7 +87,7 @@ struct DashboardView: View { ) } Divider() // Menu separator - Button("禁用") { + Button(L10n.currentString(L10nKeys.dashboardDisable)) { viewModel.requestDisable(item: item) } } @@ -182,23 +182,23 @@ struct DashboardView: View { viewModel.reloadDisabledSkills() viewModel.showsDisabledSkills = true } label: { - Label("已禁用技能", systemImage: "archivebox") + Label(L10n.currentString(L10nKeys.dashboardDisabledTitle), systemImage: "archivebox") } - .help("查看并恢复已禁用的技能") + .help(L10n.currentString(L10nKeys.dashboardDisabledHelp)) } } // Disable confirmation dialog // .alert similar to Android's AlertDialog or Web's confirm() - .alert("禁用技能", isPresented: $viewModel.showDisableConfirmation) { - Button("Cancel", role: .cancel) { + .alert(L10n.currentString(L10nKeys.dashboardDisable), isPresented: $viewModel.showDisableConfirmation) { + Button(L10n.currentString(L10nKeys.commonCancel), role: .cancel) { viewModel.cancelDelete() } - Button("禁用") { + Button(L10n.currentString(L10nKeys.dashboardDisable)) { Task { await viewModel.confirmDelete() } } } message: { if let item = viewModel.itemToDisable { - Text("\"\(item.skill.displayName)\" 会移至 ~/.agents/.skilldeck-disabled,不会删除;可从“已禁用技能”恢复。") + Text(L10n.currentFormat(L10nKeys.dashboardDisableDescription, item.skill.displayName)) } } .sheet(isPresented: $viewModel.showsDisabledSkills) { diff --git a/Sources/SkillDeck/Views/Dashboard/DisabledSkillsSheet.swift b/Sources/SkillDeck/Views/Dashboard/DisabledSkillsSheet.swift index 188927b..bedf12a 100644 --- a/Sources/SkillDeck/Views/Dashboard/DisabledSkillsSheet.swift +++ b/Sources/SkillDeck/Views/Dashboard/DisabledSkillsSheet.swift @@ -11,7 +11,7 @@ struct DisabledSkillsSheet: View { NavigationStack { Group { if viewModel.disabledSkills.isEmpty { - ContentUnavailableView("没有已禁用技能", systemImage: "archivebox") + ContentUnavailableView(L10n.currentString(L10nKeys.dashboardDisabledEmpty), systemImage: "archivebox") } else { List(viewModel.disabledSkills) { record in HStack(spacing: 12) { @@ -26,7 +26,7 @@ struct DisabledSkillsSheet: View { .lineLimit(1) } Spacer() - Button("恢复") { + Button(L10n.currentString(L10nKeys.dashboardRestore)) { Task { await viewModel.restoreDisabledSkill(record) } } .buttonStyle(.borderedProminent) @@ -35,10 +35,10 @@ struct DisabledSkillsSheet: View { } } } - .navigationTitle("已禁用技能") + .navigationTitle(L10n.currentString(L10nKeys.dashboardDisabledTitle)) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("关闭") { dismiss() } + Button(L10n.currentString(L10nKeys.commonClose)) { dismiss() } } } } diff --git a/Sources/SkillDeck/Views/Projects/GlobalSkillSourceList.swift b/Sources/SkillDeck/Views/Projects/GlobalSkillSourceList.swift index 94ec294..43273d0 100644 --- a/Sources/SkillDeck/Views/Projects/GlobalSkillSourceList.swift +++ b/Sources/SkillDeck/Views/Projects/GlobalSkillSourceList.swift @@ -13,7 +13,7 @@ struct GlobalSkillSourceList: View { Label(source.name, systemImage: "tray.full") .appFont(.headline) Spacer() - Text("\(sourceSkillCount) 个技能") + Text(L10n.currentFormat(L10nKeys.projectsSkillCount, sourceSkillCount)) .appFont(.caption) .foregroundStyle(.secondary) } @@ -21,7 +21,7 @@ struct GlobalSkillSourceList: View { .appFont(.caption) .foregroundStyle(.tertiary) .lineLimit(1) - Label("\(syncedTargetCount)/\(manager.targets.count) 个工具已整体同步", systemImage: "link") + Label(L10n.currentFormat(L10nKeys.projectsSyncedToolsCount, syncedTargetCount, manager.targets.count), systemImage: "link") .appFont(.caption) .foregroundStyle(.secondary) } @@ -29,11 +29,11 @@ struct GlobalSkillSourceList: View { .tag(Optional(source.id)) } .listStyle(.inset(alternatesRowBackgrounds: true)) - .navigationTitle("全局同步") + .navigationTitle(L10n.currentString(L10nKeys.sidebarGlobalSync)) .toolbar { ToolbarItem { Button { manager.reload() } label: { Image(systemName: "arrow.clockwise") } - .help("重新扫描全局技能") + .help(L10n.currentString(L10nKeys.globalSyncRescan)) } } .onChange(of: manager.selectedProjectID) { _, projectID in diff --git a/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift b/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift index b9bf04a..e585725 100644 --- a/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift +++ b/Sources/SkillDeck/Views/Projects/ProjectManagementView.swift @@ -10,20 +10,20 @@ struct ProjectTargetList: View { Group { if manager.projects.isEmpty { ContentUnavailableView( - "尚未添加项目", + L10n.currentString(L10nKeys.projectsEmptyTitle), systemImage: "folder.badge.plus", - description: Text("添加一个项目根目录后,SkillDeck 只会扫描其直接下的 .agents/skills。") + description: Text(L10n.currentString(L10nKeys.projectsEmptyDescription)) ) } else { List(manager.projects, selection: $manager.selectedProjectID) { project in ProjectRow(project: project, manager: manager) .tag(Optional(project.id)) .contextMenu { - Button("在 Finder 中显示") { + Button(L10n.currentString(L10nKeys.commonShowInFinder)) { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: project.rootPath) } Divider() - Button("移除项目", role: .destructive) { + Button(L10n.currentString(L10nKeys.projectsRemove), role: .destructive) { manager.remove(projectID: project.id) } } @@ -31,17 +31,17 @@ struct ProjectTargetList: View { .listStyle(.inset(alternatesRowBackgrounds: true)) } } - .navigationTitle("项目") + .navigationTitle(L10n.currentString(L10nKeys.projectsTitle)) .toolbar { ToolbarItem(placement: .primaryAction) { Button(action: chooseProject) { Image(systemName: "plus") } - .help("添加项目") + .help(L10n.currentString(L10nKeys.projectsAdd)) } ToolbarItem { Button { manager.reload() } label: { Image(systemName: "arrow.clockwise") } - .help("重新扫描项目") + .help(L10n.currentString(L10nKeys.projectsRescan)) } } .onChange(of: manager.selectedProjectID) { _, projectID in @@ -54,7 +54,7 @@ struct ProjectTargetList: View { panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false - panel.message = "选择项目根目录" + panel.message = L10n.currentString(L10nKeys.projectsChooseRoot) if panel.runModal() == .OK, let url = panel.url { manager.addProject(url) } @@ -86,7 +86,7 @@ private struct ProjectRow: View { Label(project.name, systemImage: "folder") .appFont(.headline) Spacer() - Text("\(sourceCount) 个技能") + Text(L10n.currentFormat(L10nKeys.projectsSkillCount, sourceCount)) .appFont(.caption) .foregroundStyle(.secondary) } @@ -94,7 +94,7 @@ private struct ProjectRow: View { .appFont(.caption) .foregroundStyle(.tertiary) .lineLimit(1) - Label("\(fullySyncedTargets)/\(manager.targets.count) 个工具已完全同步", systemImage: "link") + Label(L10n.currentFormat(L10nKeys.projectsSyncedToolsCount, fullySyncedTargets, manager.targets.count), systemImage: "link") .appFont(.caption) .foregroundStyle(fullySyncedTargets == manager.targets.count && sourceCount > 0 ? .green : .secondary) } @@ -143,7 +143,7 @@ struct ProjectSyncDetail: View { } } } else { - ContentUnavailableView("选择项目", systemImage: "folder", description: Text("在中间列表中选择一个项目,查看它的技能同步状态。")) + ContentUnavailableView(L10n.currentString(L10nKeys.projectsSelectTitle), systemImage: "folder", description: Text(L10n.currentString(L10nKeys.projectsSelectDescription))) } } @@ -151,7 +151,7 @@ struct ProjectSyncDetail: View { VStack(alignment: .leading, spacing: 5) { Text(project.name).appFont(.title2).fontWeight(.bold) Text(project.rootURL.tildeAbbreviatedPath).appFont(.caption).foregroundStyle(.secondary).textSelection(.enabled) - Text("源:\(project.sourceSkillsURL.tildeAbbreviatedPath)").appFont(.caption).foregroundStyle(.tertiary).textSelection(.enabled) + Text(L10n.currentFormat(L10nKeys.commonSourcePath, project.sourceSkillsURL.tildeAbbreviatedPath)).appFont(.caption).foregroundStyle(.tertiary).textSelection(.enabled) } .padding() } @@ -161,10 +161,10 @@ struct ProjectSyncDetail: View { private func rulesSection(_ project: ManagedProject) -> some View { VStack(alignment: .leading, spacing: 8) { HStack { - Label("统一规则", systemImage: "doc.text") + Label(L10n.currentString(L10nKeys.projectsRulesTitle), systemImage: "doc.text") .appFont(.headline) Spacer() - Text("\(manager.linkedRuleCount)/\(manager.selectedRuleInspections.count) 已同步") + Text(L10n.currentFormat(L10nKeys.projectsRulesSyncedCount, manager.linkedRuleCount, manager.selectedRuleInspections.count)) .appFont(.caption) .foregroundStyle(manager.linkedRuleCount == manager.selectedRuleInspections.count && manager.hasSelectedRulesSource ? .green : .secondary) } @@ -181,14 +181,14 @@ struct ProjectSyncDetail: View { } label: { Image(systemName: "pencil") } - .help("编辑统一规则") + .help(L10n.currentString(L10nKeys.projectsRulesEdit)) - Button("同步全部规则") { manager.previewRuleSyncAll() } + Button(L10n.currentString(L10nKeys.projectsRulesSyncAll)) { manager.previewRuleSyncAll() } .buttonStyle(.borderedProminent) - Button("移除规则链接", role: .destructive) { manager.previewRuleRemoval() } + Button(L10n.currentString(L10nKeys.projectsRulesRemove), role: .destructive) { manager.previewRuleRemoval() } .disabled(manager.linkedRuleCount == 0) } else { - Button("新建统一规则") { manager.createRulesSource() } + Button(L10n.currentString(L10nKeys.projectsRulesCreate)) { manager.createRulesSource() } .buttonStyle(.borderedProminent) } } @@ -231,15 +231,15 @@ struct ProjectSyncDetail: View { private func skillsContent(_ inspection: ProjectInspection) -> some View { if !inspection.targetIsAvailable { ContentUnavailableView( - "未检测到项目源技能", + L10n.currentString(L10nKeys.projectsSkillsMissingTitle), systemImage: "folder.badge.questionmark", - description: Text("在项目 .agents/skills 下添加包含 SKILL.md 的技能后即可整体同步。") + description: Text(L10n.currentString(L10nKeys.projectsSkillsMissingDescription)) ) } else if inspection.skills.isEmpty { ContentUnavailableView( - "没有项目技能", + L10n.currentString(L10nKeys.projectsSkillsEmptyTitle), systemImage: "square.stack.3d.up", - description: Text("在项目 .agents/skills 下添加包含 SKILL.md 的技能。") + description: Text(L10n.currentString(L10nKeys.projectsSkillsEmptyDescription)) ) } else { List(inspection.skills) { skill in @@ -251,7 +251,7 @@ struct ProjectSyncDetail: View { .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 3) { Text(skill.name).appFont(.headline) - Text(manager.isGlobalSync ? "全局源技能,点击查看和更新" : "项目技能,点击查看和更新") + Text(L10n.currentString(manager.isGlobalSync ? L10nKeys.projectsSkillDetailGlobal : L10nKeys.projectsSkillDetailProject)) .appFont(.caption) .foregroundStyle(.tertiary) } @@ -259,7 +259,7 @@ struct ProjectSyncDetail: View { if !manager.isGlobalSync { Image(systemName: "arrow.up.circle") .foregroundStyle(.secondary) - .help("查看和更新项目技能") + .help(L10n.currentString(L10nKeys.projectsSkillDetailHelp)) } Image(systemName: "chevron.right") .appFont(.caption) @@ -275,13 +275,13 @@ struct ProjectSyncDetail: View { private func actionBar(_ inspection: ProjectInspection) -> some View { HStack { - Button("同步全部工具") { manager.previewSyncAll() } + Button(L10n.currentString(L10nKeys.projectsSyncAllTools)) { manager.previewSyncAll() } .buttonStyle(.borderedProminent) - Button("同步整个 skills 目录") { manager.previewSync() } - Button("移除同步目录链接", role: .destructive) { manager.previewRemoval() } + Button(L10n.currentString(L10nKeys.projectsSyncDirectory)) { manager.previewSync() } + Button(L10n.currentString(L10nKeys.projectsRemoveDirectory), role: .destructive) { manager.previewRemoval() } .disabled(!manager.canRemoveSelectedTarget) Spacer() - Text("源技能 \(inspection.skills.count) 项") + Text(L10n.currentFormat(L10nKeys.projectsSourceSkillCount, inspection.skills.count)) .appFont(.caption) .foregroundStyle(.secondary) } @@ -300,13 +300,13 @@ private struct ProjectChangePreview: View { private var primaryActionLabel: String { if manager.changes.count > 1 { return manager.changes.contains(where: \.needsResolution) - ? "备份后同步 \(manager.changes.count) 个工具" - : "同步 \(manager.changes.count) 个工具" + ? L10n.currentFormat(L10nKeys.syncActionBackupAll, manager.changes.count) + : L10n.currentFormat(L10nKeys.syncActionAll, manager.changes.count) } return switch manager.changes.first?.kind { - case .replaceDirectory: "备份后整体替换" - case .removeDirectoryLink: "移除目录软链" - case .createDirectoryLink, nil: "创建目录软链" + case .replaceDirectory: L10n.currentString(L10nKeys.syncActionBackupReplace) + case .removeDirectoryLink: L10n.currentString(L10nKeys.syncActionRemoveLink) + case .createDirectoryLink, nil: L10n.currentString(L10nKeys.syncActionCreateLink) } } @@ -314,20 +314,20 @@ private struct ProjectChangePreview: View { VStack(spacing: 0) { // This header matches the app's existing import sheets: compact title, close control, and a divider. HStack { - Text("项目同步变更").appFont(.headline) + Text(L10n.currentString(L10nKeys.projectsChangeTitle)).appFont(.headline) Spacer() Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("关闭") + .help(L10n.currentString(L10nKeys.commonClose)) } .padding() Divider() - Text("同步会将每个目标 skills 目录指向:\(sourcePath)") + Text(L10n.currentFormat(L10nKeys.projectsChangeDestination, sourcePath)) .appFont(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -338,7 +338,7 @@ private struct ProjectChangePreview: View { VStack(alignment: .leading, spacing: 4) { Text("\(change.kind.label):\(change.targetRoot.tildeAbbreviatedPath)") .appFont(.headline) - Text("包含 \(change.skillNames.count) 个源技能") + Text(L10n.currentFormat(L10nKeys.projectsChangeContainsSkills, change.skillNames.count)) .appFont(.caption) .foregroundStyle(.secondary) Text(change.summary).appFont(.caption).foregroundStyle(.secondary) @@ -351,7 +351,7 @@ private struct ProjectChangePreview: View { Divider() HStack { - Button("取消") { dismiss() } + Button(L10n.currentString(L10nKeys.commonCancel)) { dismiss() } Spacer() Button(primaryActionLabel) { manager.applyChanges(replacingExistingDirectory: manager.changes.contains(where: \.needsResolution)) @@ -361,21 +361,21 @@ private struct ProjectChangePreview: View { .padding() } .frame(minWidth: 560, idealWidth: 680, maxWidth: 820, minHeight: 320, maxHeight: 620) - .alert("同步失败", isPresented: Binding( + .alert(L10n.currentString(L10nKeys.projectsSyncFailed), isPresented: Binding( get: { manager.applyErrorMessage != nil }, set: { if !$0 { manager.applyErrorMessage = nil } } )) { - Button("好", role: .cancel) { + Button(L10n.currentString(L10nKeys.commonOK), role: .cancel) { manager.applyErrorMessage = nil } } message: { Text(manager.applyErrorMessage ?? "") } - .alert("同步结果", isPresented: Binding( + .alert(L10n.currentString(L10nKeys.projectsSyncResult), isPresented: Binding( get: { manager.applyResultMessage != nil }, set: { if !$0 { manager.applyResultMessage = nil } } )) { - Button("完成") { + Button(L10n.currentString(L10nKeys.commonDone)) { manager.finishApplyingChanges() dismiss() } @@ -393,29 +393,29 @@ private struct RuleChangePreview: View { private var primaryActionLabel: String { if manager.ruleChanges.contains(where: \.needsResolution) { - return "备份后同步 \(manager.ruleChanges.count) 条规则" + return L10n.currentFormat(L10nKeys.ruleActionBackup, manager.ruleChanges.count) } return manager.ruleChanges.first?.kind == .removeLink - ? "移除 \(manager.ruleChanges.count) 条规则链接" - : "同步 \(manager.ruleChanges.count) 条规则" + ? L10n.currentFormat(L10nKeys.ruleActionRemove, manager.ruleChanges.count) + : L10n.currentFormat(L10nKeys.ruleActionSync, manager.ruleChanges.count) } var body: some View { VStack(spacing: 0) { HStack { - Text("统一规则变更").appFont(.headline) + Text(L10n.currentString(L10nKeys.projectsRulesChangeTitle)).appFont(.headline) Spacer() Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("关闭") + .help(L10n.currentString(L10nKeys.commonClose)) } .padding() Divider() - Text("所有目标规则将引用同一份 AGENTS.md;Cursor 使用可识别的 .mdc 包装规则。") + Text(L10n.currentString(L10nKeys.projectsRulesChangeDescription)) .appFont(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -439,7 +439,7 @@ private struct RuleChangePreview: View { Divider() HStack { - Button("取消") { dismiss() } + Button(L10n.currentString(L10nKeys.commonCancel)) { dismiss() } Spacer() Button(primaryActionLabel) { manager.applyRuleChanges(replacingExistingRules: manager.ruleChanges.contains(where: \.needsResolution)) @@ -449,19 +449,19 @@ private struct RuleChangePreview: View { .padding() } .frame(minWidth: 560, idealWidth: 680, maxWidth: 820, minHeight: 320, maxHeight: 620) - .alert("规则同步失败", isPresented: Binding( + .alert(L10n.currentString(L10nKeys.projectsRulesFailed), isPresented: Binding( get: { manager.ruleApplyErrorMessage != nil }, set: { if !$0 { manager.ruleApplyErrorMessage = nil } } )) { - Button("好", role: .cancel) { manager.ruleApplyErrorMessage = nil } + Button(L10n.currentString(L10nKeys.commonOK), role: .cancel) { manager.ruleApplyErrorMessage = nil } } message: { Text(manager.ruleApplyErrorMessage ?? "") } - .alert("规则同步结果", isPresented: Binding( + .alert(L10n.currentString(L10nKeys.projectsRulesResult), isPresented: Binding( get: { manager.ruleApplyResultMessage != nil }, set: { if !$0 { manager.ruleApplyResultMessage = nil } } )) { - Button("完成") { + Button(L10n.currentString(L10nKeys.commonDone)) { manager.finishApplyingRuleChanges() dismiss() } diff --git a/Sources/SkillDeck/Views/Projects/ProjectSkillDetailView.swift b/Sources/SkillDeck/Views/Projects/ProjectSkillDetailView.swift index 68078d7..0357363 100644 --- a/Sources/SkillDeck/Views/Projects/ProjectSkillDetailView.swift +++ b/Sources/SkillDeck/Views/Projects/ProjectSkillDetailView.swift @@ -35,7 +35,7 @@ struct ProjectSkillDetailView: View { ProgressView() } else { ContentUnavailableView( - "无法读取 SKILL.md", + L10n.currentString(L10nKeys.projectDetailReadFailed), systemImage: "exclamationmark.triangle", description: Text(viewModel.errorMessage ?? row.sourceURL.path) ) @@ -63,19 +63,19 @@ struct ProjectSkillDetailView: View { @ViewBuilder private func repositorySection(_ skill: Skill) -> some View { VStack(alignment: .leading, spacing: 10) { - Text("项目更新").appFont(.headline) + Text(L10n.currentString(L10nKeys.projectDetailUpdate)).appFont(.headline) if let entry = skill.lockEntry { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { GridRow { - Text("来源").foregroundStyle(.secondary) + Text(L10n.currentString(L10nKeys.projectDetailSource)).foregroundStyle(.secondary) Text(entry.source).textSelection(.enabled) } GridRow { - Text("仓库").foregroundStyle(.secondary) + Text(L10n.currentString(L10nKeys.projectDetailRepository)).foregroundStyle(.secondary) Text(entry.sourceUrl).textSelection(.enabled) } GridRow { - Text("更新于").foregroundStyle(.secondary) + Text(L10n.currentString(L10nKeys.projectDetailUpdatedAt)).foregroundStyle(.secondary) Text(entry.updatedAt.formattedDate) } } @@ -86,13 +86,13 @@ struct ProjectSkillDetailView: View { ProgressView().controlSize(.small) } if viewModel.hasUpdate { - Label("发现更新", systemImage: "arrow.up.circle.fill") + Label(L10n.currentString(L10nKeys.projectDetailUpdateFound), systemImage: "arrow.up.circle.fill") .foregroundStyle(.orange) - Button("更新") { Task { await viewModel.update() } } + Button(L10n.currentString(L10nKeys.projectDetailUpdateNow)) { Task { await viewModel.update() } } .buttonStyle(.borderedProminent) .disabled(viewModel.isUpdating) } else { - Button("检查更新") { Task { await viewModel.checkForUpdate() } } + Button(L10n.currentString(L10nKeys.projectDetailCheckUpdate)) { Task { await viewModel.checkForUpdate() } } .disabled(viewModel.isChecking) } } @@ -102,7 +102,7 @@ struct ProjectSkillDetailView: View { .textFieldStyle(.roundedBorder) .onSubmit { Task { await viewModel.linkRepository() } } .disabled(viewModel.isLinking) - Button("关联仓库") { Task { await viewModel.linkRepository() } } + Button(L10n.currentString(L10nKeys.projectDetailLinkRepository)) { Task { await viewModel.linkRepository() } } .disabled(viewModel.repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLinking) } } @@ -126,19 +126,19 @@ struct ProjectSkillDetailView: View { } label: { Image(systemName: "folder") } - .help("在 Finder 中显示") + .help(L10n.currentString(L10nKeys.commonShowInFinder)) Button { openInTerminal() } label: { Image(systemName: "terminal") } - .help("在终端中打开") + .help(L10n.currentString(L10nKeys.projectDetailOpenTerminal)) Button { NSWorkspace.shared.open(row.sourceURL.appendingPathComponent("SKILL.md")) } label: { Image(systemName: "pencil") } - .help("用默认编辑器打开 SKILL.md") + .help(L10n.currentString(L10nKeys.projectDetailOpenEditor)) } } diff --git a/Sources/SkillDeck/Views/Sidebar/SidebarView.swift b/Sources/SkillDeck/Views/Sidebar/SidebarView.swift index a478af6..09c573b 100644 --- a/Sources/SkillDeck/Views/Sidebar/SidebarView.swift +++ b/Sources/SkillDeck/Views/Sidebar/SidebarView.swift @@ -99,9 +99,9 @@ struct SidebarView: View { sidebarRow( item: .globalSync, - accessibilityLabel: "全局同步" + accessibilityLabel: L10n.string(L10nKeys.sidebarGlobalSync, bundle: localizationBundle, locale: locale) ) { - Label("全局同步", systemImage: "link") + Label(L10n.string(L10nKeys.sidebarGlobalSync, bundle: localizationBundle, locale: locale), systemImage: "link") } .listRowBackground( RoundedRectangle(cornerRadius: 6) @@ -110,9 +110,9 @@ struct SidebarView: View { sidebarRow( item: .projects, - accessibilityLabel: "项目" + accessibilityLabel: L10n.string(L10nKeys.sidebarProjects, bundle: localizationBundle, locale: locale) ) { - Label("项目", systemImage: "folder") + Label(L10n.string(L10nKeys.sidebarProjects, bundle: localizationBundle, locale: locale), systemImage: "folder") } .listRowBackground( RoundedRectangle(cornerRadius: 6) diff --git a/Tests/SkillDeckTests/LocalizationLookupTests.swift b/Tests/SkillDeckTests/LocalizationLookupTests.swift index 347489f..f82163c 100644 --- a/Tests/SkillDeckTests/LocalizationLookupTests.swift +++ b/Tests/SkillDeckTests/LocalizationLookupTests.swift @@ -24,4 +24,17 @@ final class LocalizationLookupTests: XCTestCase { XCTAssertEqual(result, "通用") } + + func testNewProjectSyncKeys_areLocalizedWhenEnglishIsSelected() { + let resolution = LocalizationResolver.resolve(language: .english) + + XCTAssertEqual( + L10n.string(L10nKeys.sidebarGlobalSync, bundle: resolution.bundle, locale: resolution.locale), + "Global Sync" + ) + XCTAssertEqual( + L10n.string(L10nKeys.dashboardAll, bundle: resolution.bundle, locale: resolution.locale), + "All" + ) + } } diff --git a/Tests/SkillDeckTests/LocalizationResolverTests.swift b/Tests/SkillDeckTests/LocalizationResolverTests.swift index 425c7ef..22874bf 100644 --- a/Tests/SkillDeckTests/LocalizationResolverTests.swift +++ b/Tests/SkillDeckTests/LocalizationResolverTests.swift @@ -67,23 +67,17 @@ final class LocalizationResolverTests: XCTestCase { XCTAssertEqual(resolution.bundle.bundleURL, baseBundle.bundleURL) } - func testLocalizationResolver_explicitEnglish_setsLocale_andFallsBackToBaseBundle_whenLprojMissing() { + func testLocalizationResolver_explicitEnglish_keepsBaseBundleForL10nLookup() { let baseBundle = SkillDeckResources.bundle let resolution = LocalizationResolver.resolve(language: .english, baseBundle: baseBundle) XCTAssertEqual(resolution.locale.identifier, Locale(identifier: AppLanguage.english.rawValue).identifier) - let lprojPath = baseBundle.path(forResource: AppLanguage.english.rawValue, ofType: "lproj") - if lprojPath == nil { - XCTAssertEqual(resolution.bundle.bundleURL, baseBundle.bundleURL) - } else { - XCTAssertNotEqual(resolution.bundle.bundleURL, baseBundle.bundleURL) - XCTAssertTrue(resolution.bundle.bundleURL.path.hasSuffix("\(AppLanguage.english.rawValue).lproj")) - } + XCTAssertEqual(resolution.bundle.bundleURL, baseBundle.bundleURL) } - func testLocalizationResolver_explicitSimplifiedChinese_setsLocale_andFallsBackToBaseBundle_whenLprojMissing() { + func testLocalizationResolver_explicitSimplifiedChinese_keepsBaseBundleForL10nLookup() { let baseBundle = SkillDeckResources.bundle let resolution = LocalizationResolver.resolve(language: .simplifiedChinese, baseBundle: baseBundle) @@ -93,17 +87,6 @@ final class LocalizationResolverTests: XCTestCase { Locale(identifier: AppLanguage.simplifiedChinese.rawValue).identifier ) - let candidatePaths = [ - baseBundle.path(forResource: AppLanguage.simplifiedChinese.rawValue, ofType: "lproj"), - baseBundle.path(forResource: AppLanguage.simplifiedChinese.rawValue.lowercased(), ofType: "lproj") - ] - - if candidatePaths.allSatisfy({ $0 == nil }) { - XCTAssertEqual(resolution.bundle.bundleURL, baseBundle.bundleURL) - } else { - XCTAssertNotEqual(resolution.bundle.bundleURL, baseBundle.bundleURL) - let path = resolution.bundle.bundleURL.path.lowercased() - XCTAssertTrue(path.hasSuffix("zh-hans.lproj") || path.hasSuffix("zh_hans.lproj")) - } + XCTAssertEqual(resolution.bundle.bundleURL, baseBundle.bundleURL) } }