From f79feb4ea95c972d71cb71daf1ae6dc8a49f8002 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 6 Apr 2026 10:49:22 +0200 Subject: [PATCH 01/89] Add MacOS Finder Badge --- macos/GitSameBadge/App/GitSameBadgeApp.swift | 18 + macos/GitSameBadge/Info.plist | 20 + macos/GitSameBadge/Models/AppState.swift | 97 ++++ .../GitSameBadge/Services/StatusReader.swift | 114 ++++ macos/GitSameBadge/Views/ContentView.swift | 132 +++++ macos/GitSameBadgeSync/BadgeManager.swift | 82 +++ .../GitSameBadgeSync/ContextMenuBuilder.swift | 120 +++++ macos/GitSameBadgeSync/FinderSync.swift | 100 ++++ macos/GitSameBadgeSync/Info.plist | 25 + macos/Shared/Constants.swift | 43 ++ macos/Shared/SocketProtocol.swift | 87 +++ macos/Shared/StatusModels.swift | 110 ++++ macos/com.zaai.git-same.daemon.plist | 23 + src/cli.rs | 37 ++ src/commands/daemon.rs | 498 ++++++++++++++++++ src/commands/daemon_tests.rs | 73 +++ src/commands/mod.rs | 2 + src/git/shell.rs | 206 +++++++- src/git/shell_tests.rs | 95 ++++ src/git/traits.rs | 113 +++- src/git/traits_tests.rs | 106 ++++ src/ipc/mod.rs | 73 +++ src/ipc/mod_tests.rs | 39 ++ src/ipc/status_file.rs | 90 ++++ src/ipc/status_file_tests.rs | 104 ++++ src/ipc/unix_socket.rs | 214 ++++++++ src/ipc/unix_socket_tests.rs | 108 ++++ src/lib.rs | 1 + src/types/finder_status.rs | 248 +++++++++ src/types/finder_status_tests.rs | 193 +++++++ src/types/mod.rs | 5 + 31 files changed, 3174 insertions(+), 2 deletions(-) create mode 100644 macos/GitSameBadge/App/GitSameBadgeApp.swift create mode 100644 macos/GitSameBadge/Info.plist create mode 100644 macos/GitSameBadge/Models/AppState.swift create mode 100644 macos/GitSameBadge/Services/StatusReader.swift create mode 100644 macos/GitSameBadge/Views/ContentView.swift create mode 100644 macos/GitSameBadgeSync/BadgeManager.swift create mode 100644 macos/GitSameBadgeSync/ContextMenuBuilder.swift create mode 100644 macos/GitSameBadgeSync/FinderSync.swift create mode 100644 macos/GitSameBadgeSync/Info.plist create mode 100644 macos/Shared/Constants.swift create mode 100644 macos/Shared/SocketProtocol.swift create mode 100644 macos/Shared/StatusModels.swift create mode 100644 macos/com.zaai.git-same.daemon.plist create mode 100644 src/commands/daemon.rs create mode 100644 src/commands/daemon_tests.rs create mode 100644 src/ipc/mod.rs create mode 100644 src/ipc/mod_tests.rs create mode 100644 src/ipc/status_file.rs create mode 100644 src/ipc/status_file_tests.rs create mode 100644 src/ipc/unix_socket.rs create mode 100644 src/ipc/unix_socket_tests.rs create mode 100644 src/types/finder_status.rs create mode 100644 src/types/finder_status_tests.rs diff --git a/macos/GitSameBadge/App/GitSameBadgeApp.swift b/macos/GitSameBadge/App/GitSameBadgeApp.swift new file mode 100644 index 0000000..72ff5da --- /dev/null +++ b/macos/GitSameBadge/App/GitSameBadgeApp.swift @@ -0,0 +1,18 @@ +// GitSameBadgeApp.swift +// Main entry point for the GitSameBadge host app. +// This is the seed for the future full macOS app. + +import SwiftUI + +@main +struct GitSameBadgeApp: App { + @StateObject private var appState = AppState() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + } + .windowResizability(.contentSize) + } +} diff --git a/macos/GitSameBadge/Info.plist b/macos/GitSameBadge/Info.plist new file mode 100644 index 0000000..f1e6c10 --- /dev/null +++ b/macos/GitSameBadge/Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleDisplayName + GitSameBadge + CFBundleIdentifier + com.zaai.git-same.GitSameBadge + CFBundleName + GitSameBadge + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + APPL + LSMinimumSystemVersion + 13.0 + + diff --git a/macos/GitSameBadge/Models/AppState.swift b/macos/GitSameBadge/Models/AppState.swift new file mode 100644 index 0000000..0c86a9a --- /dev/null +++ b/macos/GitSameBadge/Models/AppState.swift @@ -0,0 +1,97 @@ +// AppState.swift +// Central observable state for the host app. + +import Foundation +import SwiftUI + +class AppState: ObservableObject { + @Published var isDaemonRunning: Bool = false + @Published var daemonPID: UInt32? + @Published var lastScan: String? + @Published var repoCount: Int = 0 + @Published var workspaces: [FinderWorkspaceInfo] = [] + + private var refreshTimer: Timer? + private let statusReader = StatusReader.shared + + init() { + refresh() + // Periodically refresh daemon status + refreshTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in + self?.refresh() + } + } + + /// Refresh state from the status file. + func refresh() { + statusReader.reload() + guard let status = statusReader.currentStatus else { + isDaemonRunning = false + daemonPID = nil + lastScan = nil + repoCount = 0 + workspaces = [] + return + } + + daemonPID = status.daemonPid + lastScan = status.timestamp + repoCount = status.repos.count + workspaces = status.workspaces + + // Check if daemon PID is alive + isDaemonRunning = isProcessAlive(pid: status.daemonPid) + } + + /// Start the daemon. + func startDaemon() { + let binaryPath = GitSameBadgeConstants.daemonBinaryPath + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = ["daemon", "--foreground"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + do { + try process.run() + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.refresh() + } + } catch { + // Failed to start daemon + } + } + + /// Stop the daemon. + func stopDaemon() { + guard let pid = daemonPID else { return } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/kill") + process.arguments = ["-TERM", "\(pid)"] + try? process.run() + process.waitUntilExit() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.refresh() + } + } + + private func isProcessAlive(pid: UInt32) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/kill") + process.arguments = ["-0", "\(pid)"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + + deinit { + refreshTimer?.invalidate() + } +} diff --git a/macos/GitSameBadge/Services/StatusReader.swift b/macos/GitSameBadge/Services/StatusReader.swift new file mode 100644 index 0000000..390ee75 --- /dev/null +++ b/macos/GitSameBadge/Services/StatusReader.swift @@ -0,0 +1,114 @@ +// StatusReader.swift +// Watches the daemon's status.json file and parses it. + +import Foundation + +/// Reads and watches the Finder status JSON file. +class StatusReader { + static let shared = StatusReader() + + /// Callback invoked when the status file changes. + var onStatusUpdate: (() -> Void)? + + /// The current parsed status. + private(set) var currentStatus: FinderStatus? + + /// Lookup cache for repo status by path. + private var reposByPath: [String: FinderRepoStatus] = [:] + + /// Lookup cache for org folders. + private var orgPaths: Set = [] + + private var fileMonitor: DispatchSourceFileSystemObject? + private var fileDescriptor: Int32 = -1 + + private init() { + reload() + } + + /// Start watching the status file for changes. + func startWatching() { + let path = GitSameBadgeConstants.statusFilePath + + // Open file descriptor for monitoring + fileDescriptor = open(path, O_EVTONLY) + guard fileDescriptor >= 0 else { + // File doesn't exist yet — try again periodically + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 5) { [weak self] in + self?.startWatching() + } + return + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .rename, .delete], + queue: DispatchQueue.global(qos: .utility) + ) + + source.setEventHandler { [weak self] in + self?.reload() + DispatchQueue.main.async { + self?.onStatusUpdate?() + } + } + + source.setCancelHandler { [weak self] in + if let fd = self?.fileDescriptor, fd >= 0 { + close(fd) + self?.fileDescriptor = -1 + } + } + + fileMonitor = source + source.resume() + } + + /// Stop watching the status file. + func stopWatching() { + fileMonitor?.cancel() + fileMonitor = nil + } + + /// Reload and parse the status file. + func reload() { + let path = GitSameBadgeConstants.statusFilePath + guard let data = FileManager.default.contents(atPath: path) else { return } + + do { + let decoder = JSONDecoder() + let status = try decoder.decode(FinderStatus.self, from: data) + + // Update lookup caches + var repoMap: [String: FinderRepoStatus] = [:] + for repo in status.repos { + repoMap[repo.path] = repo + } + + var orgSet: Set = [] + for org in status.orgFolders ?? [] { + orgSet.insert(org.path) + } + + self.currentStatus = status + self.reposByPath = repoMap + self.orgPaths = orgSet + } catch { + // Ignore parse errors (file might be mid-write, though atomic rename should prevent this) + } + } + + /// Get the status for a repo at the given path. + func repoStatus(forPath path: String) -> FinderRepoStatus? { + return reposByPath[path] + } + + /// Check if the given path is an org folder. + func isOrgFolder(path: String) -> Bool { + return orgPaths.contains(path) + } + + deinit { + stopWatching() + } +} diff --git a/macos/GitSameBadge/Views/ContentView.swift b/macos/GitSameBadge/Views/ContentView.swift new file mode 100644 index 0000000..a7a70f5 --- /dev/null +++ b/macos/GitSameBadge/Views/ContentView.swift @@ -0,0 +1,132 @@ +// ContentView.swift +// Main window content for the GitSameBadge host app. + +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + NavigationSplitView { + // Sidebar + List { + Section("Status") { + NavigationLink(destination: DaemonStatusView()) { + Label("Daemon", systemImage: "server.rack") + } + } + + Section("Workspaces") { + ForEach(appState.workspaces, id: \.name) { workspace in + NavigationLink(destination: WorkspaceDetailView(workspace: workspace)) { + Label(workspace.name, systemImage: "folder") + } + } + } + + Section("Settings") { + NavigationLink(destination: SettingsView()) { + Label("Preferences", systemImage: "gear") + } + } + } + .listStyle(.sidebar) + .frame(minWidth: 180) + } detail: { + DaemonStatusView() + } + .frame(minWidth: 600, minHeight: 400) + } +} + +struct DaemonStatusView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Daemon status header + HStack { + Circle() + .fill(appState.isDaemonRunning ? Color.green : Color.red) + .frame(width: 12, height: 12) + Text(appState.isDaemonRunning ? "Daemon Running" : "Daemon Stopped") + .font(.title2) + .fontWeight(.semibold) + } + + if let pid = appState.daemonPID, appState.isDaemonRunning { + LabeledContent("PID", value: "\(pid)") + } + if let lastScan = appState.lastScan { + LabeledContent("Last Scan", value: lastScan) + } + LabeledContent("Repos Monitored", value: "\(appState.repoCount)") + + Divider() + + // Actions + HStack { + if appState.isDaemonRunning { + Button("Stop Daemon") { + appState.stopDaemon() + } + Button("Refresh") { + appState.refresh() + } + } else { + Button("Start Daemon") { + appState.startDaemon() + } + } + } + + Spacer() + + // Extension status hint + Text("To enable the Finder extension, go to:") + .font(.caption) + .foregroundColor(.secondary) + Text("System Settings > Privacy & Security > Extensions > Finder") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct WorkspaceDetailView: View { + let workspace: FinderWorkspaceInfo + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(workspace.name) + .font(.title2) + .fontWeight(.semibold) + + LabeledContent("Root", value: workspace.root) + LabeledContent("Organizations", value: workspace.orgs.joined(separator: ", ")) + + Spacer() + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct SettingsView: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Preferences") + .font(.title2) + .fontWeight(.semibold) + + Text("Settings will be available in a future update.") + .foregroundColor(.secondary) + + Spacer() + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/macos/GitSameBadgeSync/BadgeManager.swift b/macos/GitSameBadgeSync/BadgeManager.swift new file mode 100644 index 0000000..8a13a7b --- /dev/null +++ b/macos/GitSameBadgeSync/BadgeManager.swift @@ -0,0 +1,82 @@ +// BadgeManager.swift +// Registers badge images with the FinderSync controller. + +import Cocoa +import FinderSync + +enum BadgeManager { + /// Register all badge images with FinderSync. + /// Called once during extension initialization. + static func registerBadges() { + let controller = FIFinderSyncController.default() + + // Green badge: fully synced, safe to delete + if let greenImage = createBadgeImage(color: .systemGreen) { + controller.setBadgeImage(greenImage, label: "Synced", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.green) + } + + // Blue badge: synced but has important ignored files + if let blueImage = createBadgeImage(color: .systemBlue) { + controller.setBadgeImage(blueImage, label: "Has Local Config", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.blue) + } + + // Orange badge: main synced, worktrees/branches diverge + if let orangeImage = createBadgeImage(color: .systemOrange) { + controller.setBadgeImage(orangeImage, label: "Partially Synced", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.orange) + } + + // Red badge: uncommitted changes or unpushed commits + if let redImage = createBadgeImage(color: .systemRed) { + controller.setBadgeImage(redImage, label: "Uncommitted Changes", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.red) + } + + // Org folder badge + if let orgImage = createOrgBadgeImage() { + controller.setBadgeImage(orgImage, label: "Organization", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.org) + } + } + + /// Create a colored dot badge image. + private static func createBadgeImage(color: NSColor) -> NSImage? { + let size = NSSize(width: 16, height: 16) + let image = NSImage(size: size) + image.lockFocus() + + // Draw a filled circle + let rect = NSRect(x: 2, y: 2, width: 12, height: 12) + let path = NSBezierPath(ovalIn: rect) + color.setFill() + path.fill() + + // Draw a thin border + NSColor.white.withAlphaComponent(0.5).setStroke() + path.lineWidth = 1.0 + path.stroke() + + image.unlockFocus() + return image + } + + /// Create an org folder badge image. + private static func createOrgBadgeImage() -> NSImage? { + let size = NSSize(width: 16, height: 16) + let image = NSImage(size: size) + image.lockFocus() + + // Draw a building/org icon using a simple shape + let rect = NSRect(x: 3, y: 2, width: 10, height: 12) + let path = NSBezierPath(roundedRect: rect, xRadius: 1, yRadius: 1) + NSColor.systemPurple.setFill() + path.fill() + + // Draw windows + NSColor.white.setFill() + NSBezierPath(rect: NSRect(x: 5, y: 9, width: 2, height: 2)).fill() + NSBezierPath(rect: NSRect(x: 9, y: 9, width: 2, height: 2)).fill() + NSBezierPath(rect: NSRect(x: 5, y: 5, width: 2, height: 2)).fill() + NSBezierPath(rect: NSRect(x: 9, y: 5, width: 2, height: 2)).fill() + + image.unlockFocus() + return image + } +} diff --git a/macos/GitSameBadgeSync/ContextMenuBuilder.swift b/macos/GitSameBadgeSync/ContextMenuBuilder.swift new file mode 100644 index 0000000..9fd8b97 --- /dev/null +++ b/macos/GitSameBadgeSync/ContextMenuBuilder.swift @@ -0,0 +1,120 @@ +// ContextMenuBuilder.swift +// Builds the right-click context menu for git repository folders. + +import Cocoa + +enum ContextMenuBuilder { + /// Build the context menu for a repository. + static func build(for repo: FinderRepoStatus, socketClient: SocketClient) -> NSMenu { + let menu = NSMenu(title: "GitSameBadge") + + // Header with badge indicator + let badgeEmoji: String + switch repo.badge { + case .green: badgeEmoji = "\u{1F7E2}" // green circle + case .blue: badgeEmoji = "\u{1F535}" // blue circle + case .orange: badgeEmoji = "\u{1F7E0}" // orange circle + case .red: badgeEmoji = "\u{1F534}" // red circle + } + + let header = NSMenuItem(title: "\(badgeEmoji) GitSameBadge", action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + menu.addItem(NSMenuItem.separator()) + + // Branch info + menu.addItem(infoItem("Branch: \(repo.currentBranch)")) + menu.addItem(infoItem("Commits: \(repo.commitCount)")) + + // Staged / Unstaged + let changesLine = "Staged: \(repo.stagedCount) | Unstaged: \(repo.unstagedCount)" + menu.addItem(infoItem(changesLine)) + if repo.untrackedCount > 0 { + menu.addItem(infoItem("Untracked: \(repo.untrackedCount)")) + } + if repo.stashCount > 0 { + menu.addItem(infoItem("Stashes: \(repo.stashCount)")) + } + + // Branches submenu + if !repo.branches.isEmpty { + menu.addItem(NSMenuItem.separator()) + let branchesItem = NSMenuItem(title: "Branches", action: nil, keyEquivalent: "") + let branchesSubmenu = NSMenu() + + for branch in repo.branches { + let checkmark = (branch.name == repo.currentBranch) ? "\u{2713} " : " " + let syncStatus: String + if branch.synced { + syncStatus = "(synced)" + } else if branch.ahead > 0 && branch.behind > 0 { + syncStatus = "(ahead \(branch.ahead), behind \(branch.behind))" + } else if branch.ahead > 0 { + syncStatus = "(ahead \(branch.ahead))" + } else if branch.behind > 0 { + syncStatus = "(behind \(branch.behind))" + } else if branch.upstream == nil { + syncStatus = "(no upstream)" + } else { + syncStatus = "" + } + + let title = "\(checkmark)\(branch.name) \(syncStatus)" + branchesSubmenu.addItem(infoItem(title)) + } + + branchesItem.submenu = branchesSubmenu + menu.addItem(branchesItem) + } + + // Remotes submenu + if !repo.remotes.isEmpty { + let remotesItem = NSMenuItem(title: "Remotes", action: nil, keyEquivalent: "") + let remotesSubmenu = NSMenu() + for remote in repo.remotes { + remotesSubmenu.addItem(infoItem("\(remote.name): \(remote.url)")) + } + remotesItem.submenu = remotesSubmenu + menu.addItem(remotesItem) + } + + // Worktrees submenu + if !repo.worktrees.isEmpty { + let worktreesItem = NSMenuItem(title: "Worktrees", action: nil, keyEquivalent: "") + let worktreesSubmenu = NSMenu() + for wt in repo.worktrees { + let syncMark = wt.synced ? "\u{2713}" : "\u{2717}" + let branch = wt.branch ?? "detached" + worktreesSubmenu.addItem(infoItem("\(wt.path) (\(branch)) \(syncMark)")) + } + worktreesItem.submenu = worktreesSubmenu + menu.addItem(worktreesItem) + } + + // Actions + menu.addItem(NSMenuItem.separator()) + + let refreshItem = NSMenuItem( + title: "\u{21BB} Refresh Status", + action: #selector(FinderSync.refreshStatus(_:)), + keyEquivalent: "" + ) + menu.addItem(refreshItem) + + let terminalItem = NSMenuItem( + title: "Open in Terminal", + action: #selector(FinderSync.openInTerminal(_:)), + keyEquivalent: "" + ) + menu.addItem(terminalItem) + + return menu + } + + /// Create a disabled info item (non-clickable label). + private static func infoItem(_ title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } +} diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift new file mode 100644 index 0000000..6d7d88b --- /dev/null +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -0,0 +1,100 @@ +// FinderSync.swift +// macOS FinderSync extension that displays git status badges and context menus. + +import Cocoa +import FinderSync + +class FinderSync: FIFinderSync { + + let statusReader = StatusReader.shared + let socketClient = SocketClient() + + override init() { + super.init() + + // Register badge images + BadgeManager.registerBadges() + + // Start watching the status file + statusReader.onStatusUpdate = { [weak self] in + self?.updateMonitoredDirectories() + } + statusReader.startWatching() + } + + // MARK: - Monitored Directories + + private func updateMonitoredDirectories() { + guard let status = statusReader.currentStatus else { return } + + var urls = Set() + + // Add workspace roots + for workspace in status.workspaces { + urls.insert(URL(fileURLWithPath: workspace.root)) + } + + // Add custom folders + for folder in status.customFolders ?? [] { + urls.insert(URL(fileURLWithPath: folder)) + } + + FIFinderSyncController.default().directoryURLs = urls + } + + // MARK: - Badge Identifiers + + override func requestBadgeIdentifier(for url: URL) { + let path = url.path + + // Check if it's an org folder + if statusReader.isOrgFolder(path: path) { + FIFinderSyncController.default().setBadgeIdentifier( + GitSameBadgeConstants.BadgeID.org, for: url + ) + return + } + + // Check if it's a git repo + if let repoStatus = statusReader.repoStatus(forPath: path) { + let badgeID: String + switch repoStatus.badge { + case .green: badgeID = GitSameBadgeConstants.BadgeID.green + case .blue: badgeID = GitSameBadgeConstants.BadgeID.blue + case .orange: badgeID = GitSameBadgeConstants.BadgeID.orange + case .red: badgeID = GitSameBadgeConstants.BadgeID.red + } + FIFinderSyncController.default().setBadgeIdentifier(badgeID, for: url) + } + } + + // MARK: - Toolbar + + override var toolbarItemName: String { + return "GitSameBadge" + } + + override var toolbarItemToolTip: String { + return "GitSameBadge repository status" + } + + override var toolbarItemImage: NSImage { + return NSImage(named: NSImage.folderName)! + } + + // MARK: - Context Menu + + override func menu(for menuKind: FIMenuKind) -> NSMenu { + guard let targetURL = FIFinderSyncController.default().targetedURL() else { + return NSMenu() + } + + let path = targetURL.path + + if let repoStatus = statusReader.repoStatus(forPath: path) { + return ContextMenuBuilder.build(for: repoStatus, socketClient: socketClient) + } + + return NSMenu() + } +} diff --git a/macos/GitSameBadgeSync/Info.plist b/macos/GitSameBadgeSync/Info.plist new file mode 100644 index 0000000..bc642dd --- /dev/null +++ b/macos/GitSameBadgeSync/Info.plist @@ -0,0 +1,25 @@ + + + + + CFBundleDisplayName + GitSameBadge Sync + CFBundleIdentifier + com.zaai.git-same.GitSameBadge.FinderSync + CFBundleName + GitSameBadgeSync + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + XPC! + NSExtension + + NSExtensionPointIdentifier + com.apple.FinderSync + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).FinderSync + + + diff --git a/macos/Shared/Constants.swift b/macos/Shared/Constants.swift new file mode 100644 index 0000000..972ede2 --- /dev/null +++ b/macos/Shared/Constants.swift @@ -0,0 +1,43 @@ +// Constants.swift +// Shared constants between the host app and FinderSync extension. + +import Foundation + +enum GitSameBadgeConstants { + /// Path to the status JSON file. + static var statusFilePath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/.config/git-same/finder/status.json" + } + + /// Path to the Unix socket for refresh requests. + static var socketPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/.config/git-same/finder/finder.sock" + } + + /// Path to the git-same binary. + static var daemonBinaryPath: String { + // Check common installation locations + let candidates = [ + "/usr/local/bin/git-same", + "/opt/homebrew/bin/git-same", + "/usr/bin/git-same", + ] + for path in candidates { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return "git-same" // Fall back to PATH lookup + } + + /// Badge identifiers used by FinderSync. + enum BadgeID { + static let green = "git-green" + static let blue = "git-blue" + static let orange = "git-orange" + static let red = "git-red" + static let org = "org" + } +} diff --git a/macos/Shared/SocketProtocol.swift b/macos/Shared/SocketProtocol.swift new file mode 100644 index 0000000..157f06d --- /dev/null +++ b/macos/Shared/SocketProtocol.swift @@ -0,0 +1,87 @@ +// SocketProtocol.swift +// Unix socket client for sending refresh requests to the daemon. + +import Foundation +import Network + +/// Client for communicating with the git-same daemon via Unix socket. +class SocketClient { + private let socketPath: String + + init(socketPath: String = GitSameBadgeConstants.socketPath) { + self.socketPath = socketPath + } + + /// Send a command to the daemon and receive the response. + func send(_ command: String, completion: @escaping (Swift.Result) -> Void) { + let endpoint = NWEndpoint.unix(path: socketPath) + let connection = NWConnection(to: endpoint, using: .tcp) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + let message = "\(command)\n" + let data = message.data(using: .utf8)! + connection.send(content: data, completion: .contentProcessed { error in + if let error = error { + completion(.failure(error)) + connection.cancel() + return + } + // Read response + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in + if let error = error { + completion(.failure(error)) + } else if let data = data, let response = String(data: data, encoding: .utf8) { + completion(.success(response)) + } else { + completion(.success("")) + } + connection.cancel() + } + }) + case .failed(let error): + completion(.failure(error)) + default: + break + } + } + + connection.start(queue: .global(qos: .utility)) + } + + /// Ping the daemon. Returns true if it responds. + func ping(completion: @escaping (Bool) -> Void) { + send("PING") { result in + switch result { + case .success(let response): + completion(response.trimmingCharacters(in: .whitespacesAndNewlines) == "PONG") + case .failure: + completion(false) + } + } + } + + /// Request a refresh of a specific path. + func refresh(path: String, completion: @escaping (Bool) -> Void) { + send("REFRESH \(path)") { result in + completion(result.isSuccess) + } + } + + /// Request a full refresh. + func refreshAll(completion: @escaping (Bool) -> Void) { + send("REFRESH_ALL") { result in + completion(result.isSuccess) + } + } +} + +private extension Swift.Result { + var isSuccess: Bool { + switch self { + case .success: return true + case .failure: return false + } + } +} diff --git a/macos/Shared/StatusModels.swift b/macos/Shared/StatusModels.swift new file mode 100644 index 0000000..0999dde --- /dev/null +++ b/macos/Shared/StatusModels.swift @@ -0,0 +1,110 @@ +// StatusModels.swift +// Codable types matching the daemon's finder-status.json schema. + +import Foundation + +/// Badge color indicating repository health. +enum Badge: String, Codable { + case green + case blue + case orange + case red +} + +/// Branch sync status. +struct FinderBranchInfo: Codable { + let name: String + let upstream: String? + let ahead: UInt32 + let behind: UInt32 + let synced: Bool +} + +/// Remote info. +struct FinderRemoteInfo: Codable { + let name: String + let url: String +} + +/// Worktree info. +struct FinderWorktreeInfo: Codable { + let path: String + let branch: String? + let synced: Bool +} + +/// Complete status for a single repository. +struct FinderRepoStatus: Codable { + let path: String + let workspace: String? + let org: String? + let badge: Badge + let currentBranch: String + let defaultBranch: String? + let commitCount: UInt64 + let stagedCount: Int + let unstagedCount: Int + let untrackedCount: Int + let ahead: UInt32 + let behind: UInt32 + let stashCount: Int + let hasImportantIgnoredFiles: Bool + let importantIgnoredFiles: [String]? + let branches: [FinderBranchInfo] + let allBranchesSynced: Bool + let remotes: [FinderRemoteInfo] + let worktrees: [FinderWorktreeInfo] + let allWorktreesSynced: Bool + + enum CodingKeys: String, CodingKey { + case path, workspace, org, badge + case currentBranch = "current_branch" + case defaultBranch = "default_branch" + case commitCount = "commit_count" + case stagedCount = "staged_count" + case unstagedCount = "unstaged_count" + case untrackedCount = "untracked_count" + case ahead, behind + case stashCount = "stash_count" + case hasImportantIgnoredFiles = "has_important_ignored_files" + case importantIgnoredFiles = "important_ignored_files" + case branches + case allBranchesSynced = "all_branches_synced" + case remotes, worktrees + case allWorktreesSynced = "all_worktrees_synced" + } +} + +/// Organization folder inside a workspace. +struct OrgFolderInfo: Codable { + let path: String + let org: String + let workspace: String +} + +/// Workspace summary. +struct FinderWorkspaceInfo: Codable { + let name: String + let root: String + let orgs: [String] +} + +/// Top-level status file written by the daemon. +struct FinderStatus: Codable { + let version: UInt32 + let timestamp: String + let daemonPid: UInt32 + let workspaces: [FinderWorkspaceInfo] + let customFolders: [String]? + let repos: [FinderRepoStatus] + let orgFolders: [OrgFolderInfo]? + + enum CodingKeys: String, CodingKey { + case version, timestamp + case daemonPid = "daemon_pid" + case workspaces + case customFolders = "custom_folders" + case repos + case orgFolders = "org_folders" + } +} diff --git a/macos/com.zaai.git-same.daemon.plist b/macos/com.zaai.git-same.daemon.plist new file mode 100644 index 0000000..0f9052d --- /dev/null +++ b/macos/com.zaai.git-same.daemon.plist @@ -0,0 +1,23 @@ + + + + + Label + com.zaai.git-same.daemon + ProgramArguments + + /usr/local/bin/git-same + daemon + --foreground + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/git-same-daemon.log + StandardErrorPath + /tmp/git-same-daemon.err + + diff --git a/src/cli.rs b/src/cli.rs index c124d64..f197042 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -150,6 +150,23 @@ Examples: )] Reset(ResetArgs), + /// Run background daemon for Finder/file manager extension + #[command( + long_about = "Run a background daemon that monitors workspace repositories and \ + writes status data for the macOS Finder extension (or other file manager \ + plugins). The daemon periodically scans repos, computes badge colors, and \ + writes status to ~/.config/git-same/finder/status.json. It also listens \ + on a Unix socket for refresh requests from the extension.", + after_help = "\ +Examples: + gisa daemon Start daemon (daemonizes by default) + gisa daemon --foreground Run in foreground (useful for debugging) + gisa daemon --interval 60 Poll every 60 seconds + gisa daemon --status Check if daemon is running + gisa daemon --stop Stop a running daemon" + )] + Daemon(DaemonArgs), + /// Scan a directory tree for unregistered workspaces (.git-same/ folders) #[command( long_about = "Walk a directory tree looking for .git-same/ marker folders \ @@ -290,6 +307,26 @@ pub struct ResetArgs { pub force: bool, } +/// Arguments for the daemon command +#[derive(Args, Debug)] +pub struct DaemonArgs { + /// Run in foreground instead of daemonizing + #[arg(long)] + pub foreground: bool, + + /// Polling interval in seconds + #[arg(long, default_value = "30")] + pub interval: u64, + + /// Stop a running daemon + #[arg(long)] + pub stop: bool, + + /// Show daemon status (running, PID, last scan) + #[arg(long)] + pub status: bool, +} + /// Arguments for the scan command #[derive(Args, Debug)] pub struct ScanArgs { diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs new file mode 100644 index 0000000..1ed22ae --- /dev/null +++ b/src/commands/daemon.rs @@ -0,0 +1,498 @@ +//! Daemon command handler. +//! +//! Runs a background daemon that monitors workspace repositories, +//! computes Finder badge status, and writes the status JSON file. +//! Listens on a Unix socket for refresh requests from the Finder extension. + +use crate::cli::DaemonArgs; +use crate::config::{Config, WorkspaceStore}; +use crate::discovery::DiscoveryOrchestrator; +use crate::errors::Result; +use crate::git::{GitOperations, ShellGit}; +use crate::ipc::{IpcConfig, StatusFileWriter}; +use crate::output::Output; +use crate::types::finder_status::{ + compute_badge, matches_important_pattern, FinderBranchInfo, FinderRepoStatus, FinderStatus, + FinderWorkspaceInfo, FinderWorktreeInfo, OrgFolderInfo, DEFAULT_IMPORTANT_IGNORED_PATTERNS, +}; +use std::path::{Path, PathBuf}; +use tracing::{debug, error, info, warn}; + +/// Run the daemon command. +pub async fn run(args: &DaemonArgs, config: &Config, output: &Output) -> Result<()> { + let ipc_config = IpcConfig::default_path()?; + ipc_config.ensure_dir()?; + + // Handle --status: check if daemon is running + if args.status { + return show_status(&ipc_config, output); + } + + // Handle --stop: terminate running daemon + if args.stop { + return stop_daemon(&ipc_config, output); + } + + // Start the daemon + info!("Starting git-same daemon"); + output.info("Starting git-same daemon..."); + + let status_writer = StatusFileWriter::new(ipc_config.status_file_path()); + let git = ShellGit::new(); + let pid = std::process::id(); + + // Initial scan + let finder_status = scan_all_workspaces(config, &git, pid)?; + status_writer.write(&finder_status)?; + info!( + repos = finder_status.repos.len(), + "Initial scan complete, status written" + ); + output.info(&format!( + "Monitoring {} repos. Status: {}", + finder_status.repos.len(), + ipc_config.status_file_path().display() + )); + + // Set up Unix socket listener + #[cfg(unix)] + let socket_listener = crate::ipc::UnixSocketListener::new(ipc_config.socket_path()); + + #[cfg(unix)] + let tokio_listener = socket_listener.bind().await?; + + // Main daemon loop + let interval = tokio::time::Duration::from_secs(args.interval); + + loop { + tokio::select! { + // Wait for the polling interval + _ = tokio::time::sleep(interval) => { + debug!("Polling interval reached, scanning..."); + match scan_all_workspaces(config, &git, pid) { + Ok(status) => { + if let Err(e) = status_writer.write(&status) { + error!(error = %e, "Failed to write status file"); + } else { + debug!(repos = status.repos.len(), "Scan complete"); + } + } + Err(e) => { + error!(error = %e, "Scan failed"); + } + } + }, + // Accept socket connections for refresh requests + result = tokio_listener.accept() => { + match result { + Ok((stream, _)) => { + let config_clone = config.clone(); + let writer_path = status_writer.path().to_path_buf(); + tokio::spawn(async move { + handle_socket_connection(stream, &config_clone, pid, &writer_path).await; + }); + } + Err(e) => { + warn!(error = %e, "Failed to accept socket connection"); + } + } + }, + // Handle shutdown signal + _ = tokio::signal::ctrl_c() => { + info!("Received shutdown signal"); + output.info("Daemon shutting down..."); + socket_listener.cleanup(); + break; + }, + } + } + + Ok(()) +} + +/// Handle a single socket connection. +#[cfg(unix)] +async fn handle_socket_connection( + mut stream: tokio::net::UnixStream, + config: &Config, + pid: u32, + status_path: &Path, +) { + use crate::ipc::unix_socket::DaemonCommand; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let (reader, mut writer) = stream.split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + match reader.read_line(&mut line).await { + Ok(0) => return, // connection closed + Ok(_) => {} + Err(e) => { + debug!(error = %e, "Failed to read from socket"); + return; + } + } + + let cmd = DaemonCommand::parse(&line); + let git = ShellGit::new(); + + let response = match cmd { + DaemonCommand::Ping => "PONG\n".to_string(), + DaemonCommand::RefreshAll | DaemonCommand::Refresh(_) => { + if let DaemonCommand::Refresh(ref path) = cmd { + debug!(path = %path.display(), "Refresh requested"); + } + match scan_all_workspaces(config, &git, pid) { + Ok(status) => { + let file_writer = StatusFileWriter::new(status_path.to_path_buf()); + let _ = file_writer.write(&status); + } + Err(e) => error!(error = %e, "Refresh failed"), + } + "OK\n".to_string() + } + DaemonCommand::Status => { + let file_writer = StatusFileWriter::new(status_path.to_path_buf()); + match file_writer.read() { + Ok(status) => { + serde_json::to_string_pretty(&status).unwrap_or_else(|_| "ERROR\n".to_string()) + } + Err(_) => "ERROR\n".to_string(), + } + } + DaemonCommand::Unknown(cmd) => { + format!("UNKNOWN: {}\n", cmd) + } + }; + + let _ = writer.write_all(response.as_bytes()).await; + let _ = writer.flush().await; +} + +/// Scan all configured workspaces and build the FinderStatus. +fn scan_all_workspaces(config: &Config, git: &ShellGit, pid: u32) -> Result { + let timestamp = chrono::Utc::now().to_rfc3339(); + let mut status = FinderStatus::new(pid, timestamp); + + for ws_path in &config.workspaces { + let expanded = shellexpand::tilde(ws_path).to_string(); + let root = PathBuf::from(&expanded); + if !root.exists() { + debug!(path = %root.display(), "Workspace root does not exist, skipping"); + continue; + } + + // Load workspace config + let ws_config = match WorkspaceStore::load(&root) { + Ok(ws) => ws, + Err(e) => { + debug!( + path = %root.display(), + error = %e, + "Failed to load workspace config, skipping" + ); + continue; + } + }; + + let base_path = ws_config.expanded_base_path(); + // Use directory name as workspace name + let ws_name = base_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(ws_path) + .to_string(); + let structure = ws_config.structure.as_deref().unwrap_or(&config.structure); + + // orgs is Vec directly + let org_names: Vec = ws_config.orgs.clone(); + + status.workspaces.push(FinderWorkspaceInfo { + name: ws_name.clone(), + root: base_path.clone(), + orgs: org_names.clone(), + }); + + // Add org folder entries — scan filesystem for org directories + // If orgs list is specified, use it; otherwise discover from directory listing + let org_dirs: Vec = if org_names.is_empty() { + // Discover org directories from filesystem + std::fs::read_dir(&base_path) + .ok() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) + .filter(|e| { + e.file_name() + .to_str() + .map(|n| !n.starts_with('.')) + .unwrap_or(false) + }) + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default() + } else { + org_names.clone() + }; + + for org_name in &org_dirs { + let org_path = base_path.join(org_name); + if org_path.exists() { + status.org_folders.push(OrgFolderInfo { + path: org_path, + org: org_name.clone(), + workspace: ws_name.clone(), + }); + } + } + + // Scan local repos + let orchestrator = + DiscoveryOrchestrator::new(ws_config.filters.clone(), structure.to_string()); + let local_repos = orchestrator.scan_local(&base_path, git); + + for (repo_path, org, _name) in local_repos { + let repo_status = scan_single_repo(git, &repo_path, Some(&ws_name), Some(&org)); + status.repos.push(repo_status); + } + } + + Ok(status) +} + +/// Scan a single repository and build its FinderRepoStatus. +fn scan_single_repo( + git: &dyn GitOperations, + repo_path: &Path, + workspace: Option<&str>, + org: Option<&str>, +) -> FinderRepoStatus { + // Get basic status + let repo_status = git + .status(repo_path) + .unwrap_or_else(|_| crate::git::RepoStatus { + branch: "unknown".to_string(), + is_uncommitted: false, + ahead: 0, + behind: 0, + has_untracked: false, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + }); + + // Get branches + let branches: Vec = git + .list_branches(repo_path) + .unwrap_or_default() + .into_iter() + .map(|b| FinderBranchInfo { + name: b.name, + upstream: b.upstream, + ahead: b.ahead, + behind: b.behind, + synced: b.is_synced, + }) + .collect(); + + let all_branches_synced = branches.iter().all(|b| b.synced); + + // Get remotes + let remotes: Vec = git + .list_remotes(repo_path) + .unwrap_or_default() + .into_iter() + .map(|r| crate::types::finder_status::FinderRemoteInfo { + name: r.name, + url: r.fetch_url, + }) + .collect(); + + // Get worktrees + let worktree_infos = git.list_worktrees(repo_path).unwrap_or_default(); + let mut worktrees = Vec::new(); + let mut all_worktrees_synced = true; + + for wt in &worktree_infos { + // Skip the main worktree (same as repo_path) + if wt.path == repo_path { + continue; + } + // Check worktree status + let wt_synced = if wt.is_bare || wt.is_detached { + true + } else { + git.status(&wt.path) + .map(|s| s.is_clean_and_synced()) + .unwrap_or(false) + }; + if !wt_synced { + all_worktrees_synced = false; + } + worktrees.push(FinderWorktreeInfo { + path: wt.path.clone(), + branch: wt.branch.clone(), + synced: wt_synced, + }); + } + + // Get commit count + let commit_count = git.commit_count(repo_path).unwrap_or(0); + + // Get stash count + let stash_count = git.stash_count(repo_path).unwrap_or(0); + + // Check for important ignored files (only if otherwise clean) + let is_otherwise_clean = repo_status.staged_count == 0 + && repo_status.unstaged_count == 0 + && repo_status.untracked_count == 0 + && repo_status.ahead == 0 + && all_branches_synced + && all_worktrees_synced; + + let (has_important_ignored_files, important_ignored_files) = if is_otherwise_clean { + check_important_ignored_files(git, repo_path) + } else { + (false, Vec::new()) + }; + + // Compute badge + let badge = compute_badge( + repo_status.staged_count, + repo_status.unstaged_count, + repo_status.untracked_count, + repo_status.ahead, + all_branches_synced, + all_worktrees_synced, + has_important_ignored_files, + ); + + FinderRepoStatus { + path: repo_path.to_path_buf(), + workspace: workspace.map(|s| s.to_string()), + org: org.map(|s| s.to_string()), + badge, + current_branch: repo_status.branch, + default_branch: None, // Could be determined from remote HEAD + commit_count, + staged_count: repo_status.staged_count, + unstaged_count: repo_status.unstaged_count, + untracked_count: repo_status.untracked_count, + ahead: repo_status.ahead, + behind: repo_status.behind, + stash_count, + has_important_ignored_files, + important_ignored_files, + branches, + all_branches_synced, + remotes, + worktrees, + all_worktrees_synced, + } +} + +/// Check if a repo has important ignored files matching the configured patterns. +fn check_important_ignored_files(git: &dyn GitOperations, repo_path: &Path) -> (bool, Vec) { + let ignored_files = match git.list_ignored_files(repo_path) { + Ok(files) => files, + Err(_) => return (false, Vec::new()), + }; + + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + let important: Vec = ignored_files + .into_iter() + .filter(|f| matches_important_pattern(f, patterns)) + .collect(); + + let has_any = !important.is_empty(); + (has_any, important) +} + +/// Show daemon status. +fn show_status(ipc_config: &IpcConfig, output: &Output) -> Result<()> { + let status_path = ipc_config.status_file_path(); + if !status_path.exists() { + output.info("Daemon is not running (no status file found)"); + return Ok(()); + } + + let writer = StatusFileWriter::new(status_path); + match writer.read() { + Ok(status) => { + // Check if the PID is still alive + let pid = status.daemon_pid; + let is_alive = is_process_alive(pid); + + if is_alive { + output.info(&format!("Daemon is running (PID: {})", pid)); + } else { + output.info(&format!("Daemon is not running (stale PID: {})", pid)); + } + output.info(&format!("Last scan: {}", status.timestamp)); + output.info(&format!("Repos monitored: {}", status.repos.len())); + output.info(&format!( + "Workspaces: {}", + status + .workspaces + .iter() + .map(|w| w.name.as_str()) + .collect::>() + .join(", ") + )); + } + Err(e) => { + output.warn(&format!("Could not read status file: {}", e)); + } + } + Ok(()) +} + +/// Stop a running daemon. +fn stop_daemon(ipc_config: &IpcConfig, output: &Output) -> Result<()> { + let status_path = ipc_config.status_file_path(); + if !status_path.exists() { + output.info("No daemon is running"); + return Ok(()); + } + + let writer = StatusFileWriter::new(status_path); + match writer.read() { + Ok(status) => { + let pid = status.daemon_pid; + if is_process_alive(pid) { + // Send SIGTERM via kill command + let _ = std::process::Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + output.info(&format!("Sent stop signal to daemon (PID: {})", pid)); + } else { + output.info("Daemon is not running (stale status file)"); + } + } + Err(_) => { + output.info("Could not read daemon status"); + } + } + Ok(()) +} + +/// Check if a process with the given PID is alive. +fn is_process_alive(pid: u32) -> bool { + // Use `kill -0` via shell — avoids libc dependency + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +#[cfg(test)] +#[path = "daemon_tests.rs"] +mod tests; diff --git a/src/commands/daemon_tests.rs b/src/commands/daemon_tests.rs new file mode 100644 index 0000000..32c703a --- /dev/null +++ b/src/commands/daemon_tests.rs @@ -0,0 +1,73 @@ +use super::*; +use crate::git::traits::mock::{MockConfig, MockGit}; +use crate::git::traits::RepoStatus; +use crate::types::finder_status::Badge; + +#[test] +fn test_scan_single_repo_clean() { + let mock = MockGit::new(); + let status = scan_single_repo(&mock, Path::new("/tmp/repo"), Some("ws"), Some("org")); + + assert_eq!(status.badge, Badge::Green); + assert_eq!(status.current_branch, "main"); + assert_eq!(status.staged_count, 0); + assert_eq!(status.unstaged_count, 0); + assert_eq!(status.workspace, Some("ws".to_string())); + assert_eq!(status.org, Some("org".to_string())); + assert!(status.all_branches_synced); +} + +#[test] +fn test_scan_single_repo_dirty() { + let config = MockConfig { + default_status: RepoStatus { + branch: "feature".to_string(), + is_uncommitted: true, + ahead: 2, + behind: 0, + has_untracked: true, + staged_count: 1, + unstaged_count: 3, + untracked_count: 2, + }, + ..Default::default() + }; + let mock = MockGit::with_config(config); + let status = scan_single_repo(&mock, Path::new("/tmp/repo"), None, None); + + assert_eq!(status.badge, Badge::Red); + assert_eq!(status.current_branch, "feature"); + assert_eq!(status.staged_count, 1); + assert_eq!(status.unstaged_count, 3); + assert_eq!(status.untracked_count, 2); + assert_eq!(status.ahead, 2); +} + +#[test] +fn test_scan_single_repo_no_workspace() { + let mock = MockGit::new(); + let status = scan_single_repo(&mock, Path::new("/tmp/repo"), None, None); + + assert!(status.workspace.is_none()); + assert!(status.org.is_none()); +} + +#[test] +fn test_is_process_alive_self() { + let pid = std::process::id(); + assert!(is_process_alive(pid)); +} + +#[test] +fn test_is_process_alive_nonexistent() { + // PID 99999 is very unlikely to exist + assert!(!is_process_alive(99999)); +} + +#[test] +fn test_check_important_ignored_files_none() { + let mock = MockGit::new(); + let (has, files) = check_important_ignored_files(&mock, Path::new("/tmp/repo")); + assert!(!has); + assert!(files.is_empty()); +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1ae8e43..4d8a0e7 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ //! This module contains the runtime behavior for each subcommand, //! separated from `main.rs` so the entrypoint stays focused on bootstrapping. +pub mod daemon; pub mod init; pub mod reset; pub mod scan; @@ -59,6 +60,7 @@ pub async fn run_command( Command::Init(_) | Command::Reset(_) | Command::Scan(_) => unreachable!(), #[cfg(feature = "tui")] Command::Setup(_) => unreachable!(), + Command::Daemon(args) => daemon::run(args, &config, output).await, Command::Sync(args) => run_sync_cmd(args, &config, output).await, Command::Status(args) => run_status(args, &config, output).await, Command::Workspace(args) => workspace::run(args, &config, output), diff --git a/src/git/shell.rs b/src/git/shell.rs index 7dd2d71..bdfc2c6 100644 --- a/src/git/shell.rs +++ b/src/git/shell.rs @@ -4,7 +4,10 @@ //! by invoking git commands through the shell. use crate::errors::GitError; -use crate::git::traits::{CloneOptions, FetchResult, GitOperations, PullResult, RepoStatus}; +use crate::git::traits::{ + BranchInfo, CloneOptions, FetchResult, GitOperations, PullResult, RemoteInfo, RepoStatus, + WorktreeInfo, +}; use std::path::Path; use std::process::{Command, Output}; use tracing::{debug, trace}; @@ -107,6 +110,26 @@ impl ShellGit { } } + /// Parses the %(upstream:track) format from for-each-ref. + /// Format: "[ahead N]", "[behind N]", "[ahead N, behind M]", or "" (synced/no upstream). + fn parse_track_info(track: &str) -> (u32, u32) { + let mut ahead = 0; + let mut behind = 0; + if let Some(start) = track.find('[') { + if let Some(end) = track.find(']') { + let content = &track[start + 1..end]; + for part in content.split(", ") { + if let Some(n) = part.strip_prefix("ahead ") { + ahead = n.parse().unwrap_or(0); + } else if let Some(n) = part.strip_prefix("behind ") { + behind = n.parse().unwrap_or(0); + } + } + } + } + (ahead, behind) + } + /// Parses branch info from git status -b --porcelain output. fn parse_branch_info(&self, output: &str) -> (String, u32, u32) { let first_line = output.lines().next().unwrap_or(""); @@ -355,6 +378,187 @@ impl GitOperations for ShellGit { } Ok(output.lines().map(|l| l.to_string()).collect()) } + + fn list_branches(&self, repo_path: &Path) -> Result, GitError> { + // Use for-each-ref to get branch name, upstream, and tracking status in one call + let output = self.run_git_output( + &[ + "for-each-ref", + "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", + "refs/heads/", + ], + Some(repo_path), + )?; + + let mut branches = Vec::new(); + for line in output.lines() { + if line.is_empty() { + continue; + } + let parts: Vec<&str> = line.splitn(3, '\t').collect(); + let name = parts.first().unwrap_or(&"").to_string(); + if name.is_empty() { + continue; + } + + let upstream_raw = parts.get(1).unwrap_or(&"").to_string(); + let upstream = if upstream_raw.is_empty() { + None + } else { + Some(upstream_raw) + }; + + let track = parts.get(2).unwrap_or(&""); + let (ahead, behind) = Self::parse_track_info(track); + let is_synced = upstream.is_some() && ahead == 0 && behind == 0; + + branches.push(BranchInfo { + name, + upstream, + ahead, + behind, + is_synced, + }); + } + Ok(branches) + } + + fn list_remotes(&self, repo_path: &Path) -> Result, GitError> { + let output = self.run_git_output(&["remote", "-v"], Some(repo_path))?; + if output.is_empty() { + return Ok(Vec::new()); + } + + // git remote -v outputs pairs: "origin\turl (fetch)" and "origin\turl (push)" + // Collect into a map keyed by remote name + let mut remotes: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + + for line in output.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 3 { + continue; + } + let name = parts[0].to_string(); + let url = parts[1].to_string(); + let kind = parts[2]; // "(fetch)" or "(push)" + + let entry = remotes + .entry(name) + .or_insert_with(|| (String::new(), String::new())); + if kind == "(fetch)" { + entry.0 = url; + } else if kind == "(push)" { + entry.1 = url; + } + } + + Ok(remotes + .into_iter() + .map(|(name, (fetch_url, push_url))| RemoteInfo { + name, + fetch_url: fetch_url.clone(), + push_url: if push_url.is_empty() { + fetch_url + } else { + push_url + }, + }) + .collect()) + } + + fn list_worktrees(&self, repo_path: &Path) -> Result, GitError> { + let output = self.run_git_output(&["worktree", "list", "--porcelain"], Some(repo_path))?; + if output.is_empty() { + return Ok(Vec::new()); + } + + // Porcelain format: blocks separated by blank lines + // Each block: "worktree \nHEAD \nbranch refs/heads/\n" + // Or: "worktree \nHEAD \ndetached\n" + // Or: "worktree \nbare\n" + let mut worktrees = Vec::new(); + let mut current_path: Option = None; + let mut current_branch: Option = None; + let mut is_bare = false; + let mut is_detached = false; + + for line in output.lines() { + if line.is_empty() { + // End of a worktree block + if let Some(path) = current_path.take() { + worktrees.push(WorktreeInfo { + path, + branch: current_branch.take(), + is_bare, + is_detached, + }); + } + is_bare = false; + is_detached = false; + continue; + } + + if let Some(path_str) = line.strip_prefix("worktree ") { + current_path = Some(std::path::PathBuf::from(path_str)); + } else if let Some(branch_ref) = line.strip_prefix("branch ") { + // Strip "refs/heads/" prefix to get short branch name + current_branch = Some( + branch_ref + .strip_prefix("refs/heads/") + .unwrap_or(branch_ref) + .to_string(), + ); + } else if line == "bare" { + is_bare = true; + } else if line == "detached" { + is_detached = true; + } + } + + // Handle last block (porcelain output may not end with blank line) + if let Some(path) = current_path.take() { + worktrees.push(WorktreeInfo { + path, + branch: current_branch.take(), + is_bare, + is_detached, + }); + } + + Ok(worktrees) + } + + fn commit_count(&self, repo_path: &Path) -> Result { + let output = self.run_git_output(&["rev-list", "--count", "HEAD"], Some(repo_path))?; + output.parse().map_err(|_| { + GitError::command_failed( + "git rev-list --count HEAD", + format!("Could not parse commit count: '{}'", output), + ) + }) + } + + fn stash_count(&self, repo_path: &Path) -> Result { + let output = self.run_git_output(&["stash", "list"], Some(repo_path)); + match output { + Ok(s) if s.is_empty() => Ok(0), + Ok(s) => Ok(s.lines().count()), + // git stash list returns error on repos with no stashes in some git versions + Err(_) => Ok(0), + } + } + + fn list_ignored_files(&self, repo_path: &Path) -> Result, GitError> { + let output = self.run_git_output( + &["ls-files", "--others", "--ignored", "--exclude-standard"], + Some(repo_path), + )?; + if output.is_empty() { + return Ok(Vec::new()); + } + Ok(output.lines().map(|l| l.to_string()).collect()) + } } #[cfg(test)] diff --git a/src/git/shell_tests.rs b/src/git/shell_tests.rs index 96bc1e5..5aef11f 100644 --- a/src/git/shell_tests.rs +++ b/src/git/shell_tests.rs @@ -89,6 +89,35 @@ fn test_parse_status_mixed() { assert_eq!(status.behind, 2); } +// Unit tests for parse_track_info +#[test] +fn test_parse_track_info_empty() { + let (ahead, behind) = ShellGit::parse_track_info(""); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); +} + +#[test] +fn test_parse_track_info_ahead() { + let (ahead, behind) = ShellGit::parse_track_info("[ahead 5]"); + assert_eq!(ahead, 5); + assert_eq!(behind, 0); +} + +#[test] +fn test_parse_track_info_behind() { + let (ahead, behind) = ShellGit::parse_track_info("[behind 3]"); + assert_eq!(ahead, 0); + assert_eq!(behind, 3); +} + +#[test] +fn test_parse_track_info_diverged() { + let (ahead, behind) = ShellGit::parse_track_info("[ahead 2, behind 7]"); + assert_eq!(ahead, 2); + assert_eq!(behind, 7); +} + // Integration tests that require actual git repo #[test] #[ignore] // Run with: cargo test -- --ignored @@ -120,3 +149,69 @@ fn test_status_real() { // Should have a branch assert!(!status.branch.is_empty()); } + +#[test] +#[ignore] +fn test_list_branches_real() { + let git = ShellGit::new(); + let branches = git.list_branches(Path::new(".")); + assert!(branches.is_ok()); + let branches = branches.unwrap(); + // Should have at least one branch + assert!(!branches.is_empty()); + // At least one branch should have a name + assert!(!branches[0].name.is_empty()); +} + +#[test] +#[ignore] +fn test_list_remotes_real() { + let git = ShellGit::new(); + let remotes = git.list_remotes(Path::new(".")); + assert!(remotes.is_ok()); + let remotes = remotes.unwrap(); + // Should have at least one remote (origin) + assert!(!remotes.is_empty()); + assert_eq!(remotes[0].name, "origin"); + assert!(!remotes[0].fetch_url.is_empty()); +} + +#[test] +#[ignore] +fn test_list_worktrees_real() { + let git = ShellGit::new(); + let worktrees = git.list_worktrees(Path::new(".")); + assert!(worktrees.is_ok()); + let worktrees = worktrees.unwrap(); + // Should have at least the main worktree + assert!(!worktrees.is_empty()); + assert!(worktrees[0].path.exists()); +} + +#[test] +#[ignore] +fn test_commit_count_real() { + let git = ShellGit::new(); + let count = git.commit_count(Path::new(".")); + assert!(count.is_ok()); + // Should have at least 1 commit + assert!(count.unwrap() > 0); +} + +#[test] +#[ignore] +fn test_stash_count_real() { + let git = ShellGit::new(); + let count = git.stash_count(Path::new(".")); + assert!(count.is_ok()); + // Just check it doesn't error; count could be 0 +} + +#[test] +#[ignore] +fn test_list_ignored_files_real() { + let git = ShellGit::new(); + let files = git.list_ignored_files(Path::new(".")); + assert!(files.is_ok()); + // Just check it doesn't error; could be empty +} diff --git a/src/git/traits.rs b/src/git/traits.rs index d52d325..44093a7 100644 --- a/src/git/traits.rs +++ b/src/git/traits.rs @@ -4,7 +4,7 @@ //! allowing for both real and mock implementations for testing. use crate::errors::GitError; -use std::path::Path; +use std::path::{Path, PathBuf}; /// Options for cloning a repository. #[derive(Debug, Clone, Default)] @@ -75,6 +75,45 @@ impl RepoStatus { } } +/// Information about a local branch and its upstream tracking status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchInfo { + /// Local branch name + pub name: String, + /// Upstream tracking branch (e.g., "origin/main") + pub upstream: Option, + /// Commits ahead of upstream + pub ahead: u32, + /// Commits behind upstream + pub behind: u32, + /// Whether branch is fully synced (ahead == 0 && behind == 0 && has upstream) + pub is_synced: bool, +} + +/// Information about a git remote. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteInfo { + /// Remote name (e.g., "origin") + pub name: String, + /// Fetch URL + pub fetch_url: String, + /// Push URL (may differ from fetch URL) + pub push_url: String, +} + +/// Information about a git worktree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeInfo { + /// Absolute path to the worktree + pub path: PathBuf, + /// Branch checked out in this worktree + pub branch: Option, + /// Whether this is a bare repository + pub is_bare: bool, + /// Whether HEAD is detached + pub is_detached: bool, +} + /// Result of a fetch operation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FetchResult { @@ -152,6 +191,44 @@ pub trait GitOperations: Send + Sync { /// * `repo_path` - Path to the local repository /// * `limit` - Maximum number of commits to return fn recent_commits(&self, repo_path: &Path, limit: usize) -> Result, GitError>; + + /// Lists all local branches with their upstream tracking status. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn list_branches(&self, repo_path: &Path) -> Result, GitError>; + + /// Lists all configured remotes with their URLs. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn list_remotes(&self, repo_path: &Path) -> Result, GitError>; + + /// Lists all worktrees for a repository. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn list_worktrees(&self, repo_path: &Path) -> Result, GitError>; + + /// Gets the total number of commits on the current branch. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn commit_count(&self, repo_path: &Path) -> Result; + + /// Gets the number of stash entries. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn stash_count(&self, repo_path: &Path) -> Result; + + /// Lists ignored files that exist on disk. + /// + /// Returns file paths relative to the repository root. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn list_ignored_files(&self, repo_path: &Path) -> Result, GitError>; } /// A mock implementation of GitOperations for testing. @@ -388,6 +465,40 @@ pub mod mock { ) -> Result, GitError> { Ok(Vec::new()) } + + fn list_branches(&self, _repo_path: &Path) -> Result, GitError> { + Ok(vec![BranchInfo { + name: self.config.default_status.branch.clone(), + upstream: Some(format!("origin/{}", self.config.default_status.branch)), + ahead: 0, + behind: 0, + is_synced: true, + }]) + } + + fn list_remotes(&self, _repo_path: &Path) -> Result, GitError> { + Ok(vec![RemoteInfo { + name: "origin".to_string(), + fetch_url: "git@github.com:example/repo.git".to_string(), + push_url: "git@github.com:example/repo.git".to_string(), + }]) + } + + fn list_worktrees(&self, _repo_path: &Path) -> Result, GitError> { + Ok(Vec::new()) + } + + fn commit_count(&self, _repo_path: &Path) -> Result { + Ok(42) + } + + fn stash_count(&self, _repo_path: &Path) -> Result { + Ok(0) + } + + fn list_ignored_files(&self, _repo_path: &Path) -> Result, GitError> { + Ok(Vec::new()) + } } } diff --git a/src/git/traits_tests.rs b/src/git/traits_tests.rs index a90dcc7..80db8c4 100644 --- a/src/git/traits_tests.rs +++ b/src/git/traits_tests.rs @@ -75,6 +75,67 @@ fn test_repo_status_can_fast_forward() { assert!(!diverged.can_fast_forward()); } +#[test] +fn test_branch_info_synced() { + let branch = BranchInfo { + name: "main".to_string(), + upstream: Some("origin/main".to_string()), + ahead: 0, + behind: 0, + is_synced: true, + }; + assert!(branch.is_synced); + assert_eq!(branch.name, "main"); +} + +#[test] +fn test_branch_info_no_upstream() { + let branch = BranchInfo { + name: "local-only".to_string(), + upstream: None, + ahead: 0, + behind: 0, + is_synced: false, + }; + assert!(!branch.is_synced); +} + +#[test] +fn test_remote_info() { + let remote = RemoteInfo { + name: "origin".to_string(), + fetch_url: "git@github.com:user/repo.git".to_string(), + push_url: "git@github.com:user/repo.git".to_string(), + }; + assert_eq!(remote.name, "origin"); + assert_eq!(remote.fetch_url, remote.push_url); +} + +#[test] +fn test_worktree_info() { + let wt = WorktreeInfo { + path: Path::new("/tmp/worktree").to_path_buf(), + branch: Some("feature".to_string()), + is_bare: false, + is_detached: false, + }; + assert_eq!(wt.branch.as_deref(), Some("feature")); + assert!(!wt.is_bare); + assert!(!wt.is_detached); +} + +#[test] +fn test_worktree_info_detached() { + let wt = WorktreeInfo { + path: Path::new("/tmp/worktree").to_path_buf(), + branch: None, + is_bare: false, + is_detached: true, + }; + assert!(wt.branch.is_none()); + assert!(wt.is_detached); +} + mod mock_tests { use super::mock::*; use super::*; @@ -171,6 +232,51 @@ mod mock_tests { assert!(!mock.is_repo(Path::new("/tmp/not-a-repo"))); } + #[test] + fn test_mock_list_branches() { + let mock = MockGit::new(); + let branches = mock.list_branches(Path::new("/tmp/repo")).unwrap(); + assert_eq!(branches.len(), 1); + assert_eq!(branches[0].name, "main"); + assert!(branches[0].is_synced); + } + + #[test] + fn test_mock_list_remotes() { + let mock = MockGit::new(); + let remotes = mock.list_remotes(Path::new("/tmp/repo")).unwrap(); + assert_eq!(remotes.len(), 1); + assert_eq!(remotes[0].name, "origin"); + } + + #[test] + fn test_mock_list_worktrees() { + let mock = MockGit::new(); + let worktrees = mock.list_worktrees(Path::new("/tmp/repo")).unwrap(); + assert!(worktrees.is_empty()); + } + + #[test] + fn test_mock_commit_count() { + let mock = MockGit::new(); + let count = mock.commit_count(Path::new("/tmp/repo")).unwrap(); + assert_eq!(count, 42); + } + + #[test] + fn test_mock_stash_count() { + let mock = MockGit::new(); + let count = mock.stash_count(Path::new("/tmp/repo")).unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_mock_list_ignored_files() { + let mock = MockGit::new(); + let files = mock.list_ignored_files(Path::new("/tmp/repo")).unwrap(); + assert!(files.is_empty()); + } + #[test] fn test_mock_call_log_tracking() { let mock = MockGit::new(); diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs new file mode 100644 index 0000000..76be3ad --- /dev/null +++ b/src/ipc/mod.rs @@ -0,0 +1,73 @@ +//! IPC (Inter-Process Communication) for the daemon and Finder extension. +//! +//! This module provides cross-platform abstractions for: +//! - **Status file**: Atomic JSON writes from the daemon, read by the extension. +//! - **Socket/pipe**: Refresh requests from the extension to the daemon. +//! +//! On macOS/Linux, communication uses Unix domain sockets. +//! On Windows, named pipes are used instead. + +pub mod status_file; + +#[cfg(unix)] +pub mod unix_socket; + +pub use status_file::StatusFileWriter; + +#[cfg(unix)] +pub use unix_socket::{UnixSocketClient, UnixSocketListener}; + +use crate::errors::AppError; +use std::path::PathBuf; + +/// IPC configuration paths. +#[derive(Debug, Clone)] +pub struct IpcConfig { + /// Directory containing IPC files (status.json, finder.sock). + pub dir: PathBuf, +} + +impl IpcConfig { + /// Creates IPC config pointing to `~/.config/git-same/finder/`. + pub fn default_path() -> Result { + let config_dir = crate::config::Config::default_path()?; + // default_path returns .../config.toml, we want .../finder/ + let base_dir = config_dir + .parent() + .ok_or_else(|| AppError::config("Could not determine config directory"))?; + Ok(Self { + dir: base_dir.join("finder"), + }) + } + + /// Path to the status JSON file. + pub fn status_file_path(&self) -> PathBuf { + self.dir.join("status.json") + } + + /// Path to the Unix socket (macOS/Linux). + #[cfg(unix)] + pub fn socket_path(&self) -> PathBuf { + self.dir.join("finder.sock") + } + + /// Path to the preferences JSON file. + pub fn preferences_path(&self) -> PathBuf { + self.dir.join("preferences.json") + } + + /// Ensures the IPC directory exists. + pub fn ensure_dir(&self) -> Result<(), AppError> { + std::fs::create_dir_all(&self.dir).map_err(|e| { + AppError::path(format!( + "Failed to create IPC directory '{}': {}", + self.dir.display(), + e + )) + }) + } +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/src/ipc/mod_tests.rs b/src/ipc/mod_tests.rs new file mode 100644 index 0000000..8b46c22 --- /dev/null +++ b/src/ipc/mod_tests.rs @@ -0,0 +1,39 @@ +use super::*; + +#[test] +fn test_ipc_config_paths() { + let config = IpcConfig { + dir: PathBuf::from("/home/user/.config/git-same/finder"), + }; + assert_eq!( + config.status_file_path(), + PathBuf::from("/home/user/.config/git-same/finder/status.json") + ); + assert_eq!( + config.preferences_path(), + PathBuf::from("/home/user/.config/git-same/finder/preferences.json") + ); +} + +#[cfg(unix)] +#[test] +fn test_ipc_config_socket_path() { + let config = IpcConfig { + dir: PathBuf::from("/home/user/.config/git-same/finder"), + }; + assert_eq!( + config.socket_path(), + PathBuf::from("/home/user/.config/git-same/finder/finder.sock") + ); +} + +#[test] +fn test_ensure_dir_creates_directory() { + let temp = tempfile::tempdir().unwrap(); + let config = IpcConfig { + dir: temp.path().join("finder"), + }; + assert!(!config.dir.exists()); + config.ensure_dir().unwrap(); + assert!(config.dir.exists()); +} diff --git a/src/ipc/status_file.rs b/src/ipc/status_file.rs new file mode 100644 index 0000000..8ab0121 --- /dev/null +++ b/src/ipc/status_file.rs @@ -0,0 +1,90 @@ +//! Atomic JSON status file writer and reader. +//! +//! The daemon writes the status file atomically by writing to a temporary +//! file first, then renaming it. This ensures the FinderSync extension +//! never reads a partial/corrupt file. + +use crate::errors::AppError; +use crate::types::finder_status::FinderStatus; +use std::path::{Path, PathBuf}; + +/// Writes and reads the Finder status JSON file atomically. +#[derive(Debug, Clone)] +pub struct StatusFileWriter { + path: PathBuf, +} + +impl StatusFileWriter { + /// Creates a writer for the given status file path. + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// The path this writer writes to. + pub fn path(&self) -> &Path { + &self.path + } + + /// Writes the status atomically (write to temp, then rename). + pub fn write(&self, status: &FinderStatus) -> Result<(), AppError> { + let json = serde_json::to_string_pretty(status) + .map_err(|e| AppError::config(format!("Failed to serialize finder status: {}", e)))?; + + let temp_path = self.path.with_extension("json.tmp"); + + // Ensure parent directory exists + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + AppError::path(format!( + "Failed to create directory '{}': {}", + parent.display(), + e + )) + })?; + } + + // Write to temp file + std::fs::write(&temp_path, &json).map_err(|e| { + AppError::path(format!( + "Failed to write temp status file '{}': {}", + temp_path.display(), + e + )) + })?; + + // Atomic rename + std::fs::rename(&temp_path, &self.path).map_err(|e| { + AppError::path(format!( + "Failed to rename '{}' → '{}': {}", + temp_path.display(), + self.path.display(), + e + )) + })?; + + Ok(()) + } + + /// Reads and parses the status file. + pub fn read(&self) -> Result { + let content = std::fs::read_to_string(&self.path).map_err(|e| { + AppError::path(format!( + "Failed to read status file '{}': {}", + self.path.display(), + e + )) + })?; + + serde_json::from_str(&content) + .map_err(|e| AppError::config(format!("Failed to parse status file: {}", e))) + } + + /// Checks if the status file exists. + pub fn exists(&self) -> bool { + self.path.exists() + } +} + +#[cfg(test)] +#[path = "status_file_tests.rs"] +mod tests; diff --git a/src/ipc/status_file_tests.rs b/src/ipc/status_file_tests.rs new file mode 100644 index 0000000..56ca092 --- /dev/null +++ b/src/ipc/status_file_tests.rs @@ -0,0 +1,104 @@ +use super::*; +use crate::types::finder_status::{ + Badge, FinderBranchInfo, FinderRepoStatus, FinderStatus, FinderWorkspaceInfo, +}; +use std::path::PathBuf; + +fn sample_status() -> FinderStatus { + let mut status = FinderStatus::new(12345, "2026-04-04T10:30:00Z".to_string()); + status.workspaces.push(FinderWorkspaceInfo { + name: "github".to_string(), + root: PathBuf::from("/Users/test/repos"), + orgs: vec!["zaai-com".to_string()], + }); + status.repos.push(FinderRepoStatus { + path: PathBuf::from("/Users/test/repos/zaai-com/git-same"), + workspace: Some("github".to_string()), + org: Some("zaai-com".to_string()), + badge: Badge::Green, + current_branch: "main".to_string(), + default_branch: Some("main".to_string()), + commit_count: 847, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: vec![FinderBranchInfo { + name: "main".to_string(), + upstream: Some("origin/main".to_string()), + ahead: 0, + behind: 0, + synced: true, + }], + all_branches_synced: true, + remotes: vec![], + worktrees: Vec::new(), + all_worktrees_synced: true, + }); + status +} + +#[test] +fn test_write_and_read_roundtrip() { + let temp = tempfile::tempdir().unwrap(); + let writer = StatusFileWriter::new(temp.path().join("status.json")); + + let status = sample_status(); + writer.write(&status).unwrap(); + + assert!(writer.exists()); + + let read_back = writer.read().unwrap(); + assert_eq!(read_back, status); +} + +#[test] +fn test_write_creates_parent_dirs() { + let temp = tempfile::tempdir().unwrap(); + let writer = StatusFileWriter::new(temp.path().join("sub/dir/status.json")); + + let status = FinderStatus::new(1, "now".to_string()); + writer.write(&status).unwrap(); + + assert!(writer.exists()); +} + +#[test] +fn test_write_overwrites_existing() { + let temp = tempfile::tempdir().unwrap(); + let writer = StatusFileWriter::new(temp.path().join("status.json")); + + let status1 = FinderStatus::new(1, "first".to_string()); + writer.write(&status1).unwrap(); + + let status2 = FinderStatus::new(2, "second".to_string()); + writer.write(&status2).unwrap(); + + let read_back = writer.read().unwrap(); + assert_eq!(read_back.daemon_pid, 2); + assert_eq!(read_back.timestamp, "second"); +} + +#[test] +fn test_read_nonexistent_file() { + let writer = StatusFileWriter::new(PathBuf::from("/nonexistent/status.json")); + assert!(!writer.exists()); + assert!(writer.read().is_err()); +} + +#[test] +fn test_no_temp_file_remains_after_write() { + let temp = tempfile::tempdir().unwrap(); + let writer = StatusFileWriter::new(temp.path().join("status.json")); + + let status = FinderStatus::new(1, "now".to_string()); + writer.write(&status).unwrap(); + + // The .tmp file should not exist after atomic rename + let temp_path = temp.path().join("status.json.tmp"); + assert!(!temp_path.exists()); +} diff --git a/src/ipc/unix_socket.rs b/src/ipc/unix_socket.rs new file mode 100644 index 0000000..33c97d9 --- /dev/null +++ b/src/ipc/unix_socket.rs @@ -0,0 +1,214 @@ +//! Unix domain socket IPC for macOS and Linux. +//! +//! The daemon listens on a Unix socket for commands from the FinderSync +//! extension (or CLI tools). Commands are text-based, one per line. +//! +//! ## Protocol +//! +//! ```text +//! REFRESH /path/to/folder\n → re-scan folder + subfolders, respond "OK\n" +//! REFRESH_ALL\n → re-scan everything, respond "OK\n" +//! STATUS\n → respond with full status JSON +//! PING\n → respond "PONG\n" (health check) +//! ``` + +use crate::errors::AppError; +use std::path::{Path, PathBuf}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener as TokioUnixListener, UnixStream}; +use tracing::{debug, warn}; + +/// Commands the daemon can receive over the socket. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaemonCommand { + /// Re-scan a specific path and its subfolders. + Refresh(PathBuf), + /// Re-scan all monitored paths. + RefreshAll, + /// Return the current status JSON. + Status, + /// Health check. + Ping, + /// Unknown command. + Unknown(String), +} + +impl DaemonCommand { + /// Parse a command from a text line. + pub fn parse(line: &str) -> Self { + let trimmed = line.trim(); + if let Some(path) = trimmed.strip_prefix("REFRESH ") { + DaemonCommand::Refresh(PathBuf::from(path.trim())) + } else if trimmed == "REFRESH_ALL" { + DaemonCommand::RefreshAll + } else if trimmed == "STATUS" { + DaemonCommand::Status + } else if trimmed == "PING" { + DaemonCommand::Ping + } else { + DaemonCommand::Unknown(trimmed.to_string()) + } + } +} + +/// Unix socket listener for the daemon. +pub struct UnixSocketListener { + path: PathBuf, +} + +impl UnixSocketListener { + /// Creates a new listener for the given socket path. + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// The socket path. + pub fn path(&self) -> &Path { + &self.path + } + + /// Bind and start listening. Removes stale socket file if present. + pub async fn bind(&self) -> Result { + // Remove stale socket file from a previous run + if self.path.exists() { + std::fs::remove_file(&self.path).map_err(|e| { + AppError::path(format!( + "Failed to remove stale socket '{}': {}", + self.path.display(), + e + )) + })?; + } + + // Ensure parent directory exists + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + AppError::path(format!( + "Failed to create socket directory '{}': {}", + parent.display(), + e + )) + })?; + } + + TokioUnixListener::bind(&self.path).map_err(|e| { + AppError::path(format!( + "Failed to bind Unix socket '{}': {}", + self.path.display(), + e + )) + }) + } + + /// Cleans up the socket file on shutdown. + pub fn cleanup(&self) { + if self.path.exists() { + if let Err(e) = std::fs::remove_file(&self.path) { + warn!( + path = %self.path.display(), + error = %e, + "Failed to remove socket file during cleanup" + ); + } + } + } +} + +/// Read a single command from a connected Unix stream. +pub async fn read_command(stream: &mut BufReader) -> Result { + let mut line = String::new(); + let bytes_read = stream + .read_line(&mut line) + .await + .map_err(|e| AppError::config(format!("Failed to read from socket: {}", e)))?; + + if bytes_read == 0 { + return Err(AppError::config("Socket connection closed")); + } + + Ok(DaemonCommand::parse(&line)) +} + +/// Write a response to a connected Unix stream. +pub async fn write_response(stream: &mut UnixStream, response: &str) -> Result<(), AppError> { + stream + .write_all(response.as_bytes()) + .await + .map_err(|e| AppError::config(format!("Failed to write to socket: {}", e)))?; + stream + .flush() + .await + .map_err(|e| AppError::config(format!("Failed to flush socket: {}", e)))?; + Ok(()) +} + +/// Client for connecting to the daemon's Unix socket. +pub struct UnixSocketClient { + path: PathBuf, +} + +impl UnixSocketClient { + /// Creates a client targeting the given socket path. + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// Send a command and receive the response. + pub async fn send(&self, command: &str) -> Result { + let mut stream = UnixStream::connect(&self.path).await.map_err(|e| { + AppError::path(format!( + "Failed to connect to daemon socket '{}': {}", + self.path.display(), + e + )) + })?; + + // Send command + let msg = format!("{}\n", command); + stream + .write_all(msg.as_bytes()) + .await + .map_err(|e| AppError::config(format!("Failed to send command: {}", e)))?; + stream + .flush() + .await + .map_err(|e| AppError::config(format!("Failed to flush: {}", e)))?; + + // Read response + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader + .read_line(&mut response) + .await + .map_err(|e| AppError::config(format!("Failed to read response: {}", e)))?; + + debug!( + command, + response = response.trim(), + "Socket command completed" + ); + Ok(response) + } + + /// Ping the daemon. Returns true if it responds. + pub async fn ping(&self) -> bool { + match self.send("PING").await { + Ok(response) => response.trim() == "PONG", + Err(_) => false, + } + } + + /// Request a refresh of a specific path. + pub async fn refresh(&self, path: &Path) -> Result { + self.send(&format!("REFRESH {}", path.display())).await + } + + /// Request a full refresh of all monitored paths. + pub async fn refresh_all(&self) -> Result { + self.send("REFRESH_ALL").await + } +} + +#[cfg(test)] +#[path = "unix_socket_tests.rs"] +mod tests; diff --git a/src/ipc/unix_socket_tests.rs b/src/ipc/unix_socket_tests.rs new file mode 100644 index 0000000..9c3d9b8 --- /dev/null +++ b/src/ipc/unix_socket_tests.rs @@ -0,0 +1,108 @@ +use super::*; + +#[test] +fn test_parse_command_ping() { + assert_eq!(DaemonCommand::parse("PING"), DaemonCommand::Ping); + assert_eq!(DaemonCommand::parse("PING\n"), DaemonCommand::Ping); +} + +#[test] +fn test_parse_command_refresh() { + assert_eq!( + DaemonCommand::parse("REFRESH /path/to/repo"), + DaemonCommand::Refresh(PathBuf::from("/path/to/repo")) + ); +} + +#[test] +fn test_parse_command_refresh_all() { + assert_eq!( + DaemonCommand::parse("REFRESH_ALL"), + DaemonCommand::RefreshAll + ); +} + +#[test] +fn test_parse_command_status() { + assert_eq!(DaemonCommand::parse("STATUS"), DaemonCommand::Status); +} + +#[test] +fn test_parse_command_unknown() { + assert_eq!( + DaemonCommand::parse("FOOBAR"), + DaemonCommand::Unknown("FOOBAR".to_string()) + ); +} + +#[test] +fn test_parse_command_refresh_with_spaces_in_path() { + assert_eq!( + DaemonCommand::parse("REFRESH /path/to/my repo"), + DaemonCommand::Refresh(PathBuf::from("/path/to/my repo")) + ); +} + +#[tokio::test] +async fn test_socket_listener_bind_and_cleanup() { + let temp = tempfile::tempdir().unwrap(); + let sock_path = temp.path().join("test.sock"); + let listener = UnixSocketListener::new(sock_path.clone()); + + // Bind should succeed + let _tokio_listener = listener.bind().await.unwrap(); + assert!(sock_path.exists()); + + // Cleanup should remove the socket + listener.cleanup(); + assert!(!sock_path.exists()); +} + +#[tokio::test] +async fn test_socket_listener_removes_stale_socket() { + let temp = tempfile::tempdir().unwrap(); + let sock_path = temp.path().join("test.sock"); + + // Create a stale socket file + std::fs::write(&sock_path, "stale").unwrap(); + assert!(sock_path.exists()); + + let listener = UnixSocketListener::new(sock_path.clone()); + let _tokio_listener = listener.bind().await.unwrap(); + + // Should have removed the stale file and created a real socket + assert!(sock_path.exists()); + listener.cleanup(); +} + +#[tokio::test] +async fn test_socket_client_server_roundtrip() { + let temp = tempfile::tempdir().unwrap(); + let sock_path = temp.path().join("test.sock"); + + let listener = UnixSocketListener::new(sock_path.clone()); + let tokio_listener = listener.bind().await.unwrap(); + + // Spawn a simple server that responds to PING + let server = tokio::spawn(async move { + let (stream, _) = tokio_listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + + let cmd = DaemonCommand::parse(&line); + assert_eq!(cmd, DaemonCommand::Ping); + + let stream = reader.into_inner(); + let mut stream = stream; + write_response(&mut stream, "PONG\n").await.unwrap(); + }); + + // Client sends PING + let client = UnixSocketClient::new(sock_path); + let is_alive = client.ping().await; + assert!(is_alive); + + server.await.unwrap(); + listener.cleanup(); +} diff --git a/src/lib.rs b/src/lib.rs index c2da655..0425bfd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ pub mod domain; pub mod errors; pub mod git; pub mod infra; +pub mod ipc; pub mod operations; pub mod output; pub mod provider; diff --git a/src/types/finder_status.rs b/src/types/finder_status.rs new file mode 100644 index 0000000..8de4c71 --- /dev/null +++ b/src/types/finder_status.rs @@ -0,0 +1,248 @@ +//! Types for the Finder extension status data. +//! +//! These types define the JSON schema written by the daemon and read by +//! the FinderSync extension. They represent the complete state needed to +//! render badges, icons, and context menus. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Badge color indicating repository health. +/// +/// Priority order: Red > Orange > Blue > Green. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Badge { + /// Everything synced, no local-only data, no important ignored files. + /// Safe to delete. + Green, + /// Fully synced, but has important gitignored files (.env, keys, etc.). + /// Code is on GitHub, but local secrets/config would be lost. + Blue, + /// Main branch clean & synced, but other branches or worktrees diverge. + /// Main branch is safe; other branches or worktrees have local-only data. + Orange, + /// Staged, unstaged, untracked, or unpushed commits. + /// DO NOT delete — uncommitted work or unpushed commits would be lost. + Red, +} + +/// Branch sync status in the context menu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderBranchInfo { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub synced: bool, +} + +/// Remote info for the context menu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderRemoteInfo { + pub name: String, + pub url: String, +} + +/// Worktree info for the context menu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderWorktreeInfo { + pub path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + pub synced: bool, +} + +/// Complete status for a single repository. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderRepoStatus { + pub path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub org: Option, + pub badge: Badge, + pub current_branch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_branch: Option, + pub commit_count: u64, + pub staged_count: usize, + pub unstaged_count: usize, + pub untracked_count: usize, + pub ahead: u32, + pub behind: u32, + pub stash_count: usize, + pub has_important_ignored_files: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub important_ignored_files: Vec, + pub branches: Vec, + pub all_branches_synced: bool, + pub remotes: Vec, + pub worktrees: Vec, + pub all_worktrees_synced: bool, +} + +/// An organization folder inside a git-same workspace. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OrgFolderInfo { + pub path: PathBuf, + pub org: String, + pub workspace: String, +} + +/// Workspace summary for the status file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderWorkspaceInfo { + pub name: String, + pub root: PathBuf, + pub orgs: Vec, +} + +/// Top-level status file written by the daemon. +/// +/// This is the single source of truth read by the FinderSync extension. +/// Written atomically to `~/.config/git-same/finder/status.json`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderStatus { + pub version: u32, + pub timestamp: String, + pub daemon_pid: u32, + pub workspaces: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom_folders: Vec, + pub repos: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub org_folders: Vec, +} + +impl FinderStatus { + /// Current schema version. + pub const VERSION: u32 = 1; + + /// Creates a new empty status. + pub fn new(pid: u32, timestamp: String) -> Self { + Self { + version: Self::VERSION, + timestamp, + daemon_pid: pid, + workspaces: Vec::new(), + custom_folders: Vec::new(), + repos: Vec::new(), + org_folders: Vec::new(), + } + } +} + +/// Default patterns for detecting important gitignored files. +/// +/// These patterns indicate files that contain secrets, credentials, or +/// local configuration that would be lost if the repository were deleted. +pub const DEFAULT_IMPORTANT_IGNORED_PATTERNS: &[&str] = &[ + ".env", + ".env.*", + "*.key", + "*.pem", + "*.p12", + "*.pfx", + "credentials*", + "secrets*", + ".secret*", + "service-account*.json", + "*.keystore", +]; + +/// Compute the badge color for a repository based on its status. +pub fn compute_badge( + staged: usize, + unstaged: usize, + untracked: usize, + ahead: u32, + all_branches_synced: bool, + all_worktrees_synced: bool, + has_important_ignored_files: bool, +) -> Badge { + // Red: any local-only changes or unpushed commits + if staged > 0 || unstaged > 0 || untracked > 0 || ahead > 0 { + return Badge::Red; + } + + // Orange: main branch clean, but other branches/worktrees not synced + if !all_branches_synced || !all_worktrees_synced { + return Badge::Orange; + } + + // Blue: everything synced but has important ignored files + if has_important_ignored_files { + return Badge::Blue; + } + + // Green: fully clean and synced + Badge::Green +} + +/// Check whether a file path matches any of the important ignored patterns. +/// +/// Uses simple glob-like matching: `*` matches any characters, `?` matches one. +pub fn matches_important_pattern(file_path: &str, patterns: &[&str]) -> bool { + let filename = std::path::Path::new(file_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(file_path); + + for pattern in patterns { + if simple_glob_match(pattern, filename) { + return true; + } + } + false +} + +/// Simple glob matching supporting `*` (any chars) and `?` (single char). +fn simple_glob_match(pattern: &str, text: &str) -> bool { + glob_match_recursive( + &pattern.chars().collect::>(), + 0, + &text.chars().collect::>(), + 0, + ) +} + +fn glob_match_recursive(pattern: &[char], pi: usize, text: &[char], ti: usize) -> bool { + if pi == pattern.len() && ti == text.len() { + return true; + } + if pi == pattern.len() { + return false; + } + + match pattern[pi] { + '*' => { + // Try matching * with 0, 1, 2, ... characters + for i in ti..=text.len() { + if glob_match_recursive(pattern, pi + 1, text, i) { + return true; + } + } + false + } + '?' => { + if ti < text.len() { + glob_match_recursive(pattern, pi + 1, text, ti + 1) + } else { + false + } + } + c => { + if ti < text.len() && text[ti] == c { + glob_match_recursive(pattern, pi + 1, text, ti + 1) + } else { + false + } + } + } +} + +#[cfg(test)] +#[path = "finder_status_tests.rs"] +mod tests; diff --git a/src/types/finder_status_tests.rs b/src/types/finder_status_tests.rs new file mode 100644 index 0000000..55a8f10 --- /dev/null +++ b/src/types/finder_status_tests.rs @@ -0,0 +1,193 @@ +use super::*; + +#[test] +fn test_compute_badge_green() { + let badge = compute_badge(0, 0, 0, 0, true, true, false); + assert_eq!(badge, Badge::Green); +} + +#[test] +fn test_compute_badge_red_staged() { + let badge = compute_badge(1, 0, 0, 0, true, true, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_red_unstaged() { + let badge = compute_badge(0, 2, 0, 0, true, true, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_red_untracked() { + let badge = compute_badge(0, 0, 3, 0, true, true, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_red_ahead() { + let badge = compute_badge(0, 0, 0, 1, true, true, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_orange_branches_not_synced() { + let badge = compute_badge(0, 0, 0, 0, false, true, false); + assert_eq!(badge, Badge::Orange); +} + +#[test] +fn test_compute_badge_orange_worktrees_not_synced() { + let badge = compute_badge(0, 0, 0, 0, true, false, false); + assert_eq!(badge, Badge::Orange); +} + +#[test] +fn test_compute_badge_blue_important_ignored() { + let badge = compute_badge(0, 0, 0, 0, true, true, true); + assert_eq!(badge, Badge::Blue); +} + +#[test] +fn test_compute_badge_priority_red_over_orange() { + // Even if branches not synced, staged files = Red + let badge = compute_badge(1, 0, 0, 0, false, false, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_priority_orange_over_blue() { + // Branches not synced + important ignored = Orange (not Blue) + let badge = compute_badge(0, 0, 0, 0, false, true, true); + assert_eq!(badge, Badge::Orange); +} + +#[test] +fn test_matches_important_pattern_env() { + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + assert!(matches_important_pattern(".env", patterns)); + assert!(matches_important_pattern(".env.local", patterns)); + assert!(matches_important_pattern(".env.production", patterns)); + assert!(matches_important_pattern("subdir/.env", patterns)); +} + +#[test] +fn test_matches_important_pattern_keys() { + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + assert!(matches_important_pattern("server.key", patterns)); + assert!(matches_important_pattern("cert.pem", patterns)); + assert!(matches_important_pattern("signing.p12", patterns)); +} + +#[test] +fn test_matches_important_pattern_credentials() { + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + assert!(matches_important_pattern("credentials.json", patterns)); + assert!(matches_important_pattern("secrets.yaml", patterns)); + assert!(matches_important_pattern( + "service-account-prod.json", + patterns + )); +} + +#[test] +fn test_matches_important_pattern_no_match() { + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + assert!(!matches_important_pattern("main.rs", patterns)); + assert!(!matches_important_pattern( + "node_modules/lodash/index.js", + patterns + )); + assert!(!matches_important_pattern( + "target/debug/git-same", + patterns + )); + assert!(!matches_important_pattern("README.md", patterns)); +} + +#[test] +fn test_glob_match_exact() { + assert!(simple_glob_match(".env", ".env")); + assert!(!simple_glob_match(".env", ".envx")); +} + +#[test] +fn test_glob_match_star() { + assert!(simple_glob_match("*.key", "server.key")); + assert!(simple_glob_match("*.key", ".key")); + assert!(!simple_glob_match("*.key", "server.pem")); +} + +#[test] +fn test_glob_match_dot_star() { + assert!(simple_glob_match(".env.*", ".env.local")); + assert!(simple_glob_match(".env.*", ".env.production")); + assert!(!simple_glob_match(".env.*", ".env")); +} + +#[test] +fn test_glob_match_question_mark() { + assert!(simple_glob_match("?.key", "a.key")); + assert!(!simple_glob_match("?.key", "ab.key")); +} + +#[test] +fn test_finder_status_serialization() { + let status = FinderStatus::new(12345, "2026-04-04T10:30:00Z".to_string()); + let json = serde_json::to_string_pretty(&status).unwrap(); + assert!(json.contains("\"version\": 1")); + assert!(json.contains("\"daemon_pid\": 12345")); + + // Round-trip + let parsed: FinderStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, status); +} + +#[test] +fn test_finder_repo_status_serialization() { + let repo = FinderRepoStatus { + path: PathBuf::from("/repos/org/repo"), + workspace: Some("github".to_string()), + org: Some("org".to_string()), + badge: Badge::Green, + current_branch: "main".to_string(), + default_branch: Some("main".to_string()), + commit_count: 847, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: vec![FinderBranchInfo { + name: "main".to_string(), + upstream: Some("origin/main".to_string()), + ahead: 0, + behind: 0, + synced: true, + }], + all_branches_synced: true, + remotes: vec![FinderRemoteInfo { + name: "origin".to_string(), + url: "git@github.com:org/repo.git".to_string(), + }], + worktrees: Vec::new(), + all_worktrees_synced: true, + }; + + let json = serde_json::to_string(&repo).unwrap(); + assert!(json.contains("\"badge\":\"green\"")); + + let parsed: FinderRepoStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, repo); +} + +#[test] +fn test_badge_serialization() { + assert_eq!(serde_json::to_string(&Badge::Green).unwrap(), "\"green\""); + assert_eq!(serde_json::to_string(&Badge::Blue).unwrap(), "\"blue\""); + assert_eq!(serde_json::to_string(&Badge::Orange).unwrap(), "\"orange\""); + assert_eq!(serde_json::to_string(&Badge::Red).unwrap(), "\"red\""); +} diff --git a/src/types/mod.rs b/src/types/mod.rs index 8a235bd..3eefccf 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -11,8 +11,13 @@ //! - [`OpResult`] - Result of a single operation //! - [`OpSummary`] - Summary statistics for batch operations +pub mod finder_status; mod provider; mod repo; +pub use finder_status::{ + Badge, FinderBranchInfo, FinderRemoteInfo, FinderRepoStatus, FinderStatus, FinderWorkspaceInfo, + FinderWorktreeInfo, OrgFolderInfo, +}; pub use provider::ProviderKind; pub use repo::{ActionPlan, OpResult, OpSummary, Org, OwnedRepo, Repo, SkippedRepo}; From 9db859f242d32a0b90f6392c70c5ce32beeeef5f Mon Sep 17 00:00:00 2001 From: Manuel Date: Sun, 19 Apr 2026 16:18:39 +0200 Subject: [PATCH 02/89] Bump softprops/action-gh-release to v3 for Node 24 runtime Upstream v3.0.0 (2026-04-12) moves the action runtime from Node 20 to Node 24. Inputs, outputs, and env usage are unchanged, so the existing step config keeps working as-is. Matches the repo's pattern of tracking the newest major tag for each action. --- .github/workflows/S2-Release-GitHub.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/S2-Release-GitHub.yml b/.github/workflows/S2-Release-GitHub.yml index 53cb198..4fd9060 100644 --- a/.github/workflows/S2-Release-GitHub.yml +++ b/.github/workflows/S2-Release-GitHub.yml @@ -388,7 +388,7 @@ jobs: find artifacts -type f -exec cp {} release-assets/ \; - name: Create/update release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: release-assets/* env: From 566a52ef520fda7dd1998d950eebe40beddca9ef Mon Sep 17 00:00:00 2001 From: Manuel Date: Sun, 19 Apr 2026 16:20:03 +0200 Subject: [PATCH 03/89] Fix TUI sync hang by timing out gh calls and offloading auth to blocking pool The TUI previously froze forever at "Discovering repositories..." when any gh CLI subprocess stalled, because blocking Command::output() ran directly on the Tokio runtime with no timeouts. - Add run_gh_with_timeout() in src/auth/gh_cli.rs: 10s hard timeout using Command::spawn() + try_wait() polling + child.kill() on expiry. Refactor is_installed, is_authenticated, get_token, get_username, get_token_for_host to use it. - Wrap get_auth_for_provider() in tokio::task::spawn_blocking at the two async call sites (workflows/sync_workspace.rs and setup/handler.rs) so the async runtime stays responsive while gh is running. --- src/auth/gh_cli.rs | 81 +++++++++++++++++++++++---------- src/auth/gh_cli_tests.rs | 5 ++ src/setup/handler.rs | 19 ++++++-- src/workflows/sync_workspace.rs | 9 +++- 4 files changed, 85 insertions(+), 29 deletions(-) diff --git a/src/auth/gh_cli.rs b/src/auth/gh_cli.rs index 81cff3b..5972094 100644 --- a/src/auth/gh_cli.rs +++ b/src/auth/gh_cli.rs @@ -3,32 +3,76 @@ //! Uses the `gh` CLI tool to obtain authentication tokens securely. use crate::errors::AppError; -use std::process::Command; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +/// Maximum time to wait for any `gh` subprocess to complete. +pub(crate) const GH_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); + +/// Run a `gh` subcommand with a hard timeout, killing the child on expiry. +/// +/// Prevents the async runtime from being blocked indefinitely if `gh` stalls +/// (e.g. on network issues, interactive prompts, or a wedged SSH agent). +fn run_gh_with_timeout(args: &[&str]) -> Result { + let mut child = Command::new("gh") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| AppError::auth(format!("Failed to spawn 'gh {}': {}", args.join(" "), e)))?; + + let deadline = Instant::now() + GH_COMMAND_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) => { + return child.wait_with_output().map_err(|e| { + AppError::auth(format!( + "Failed to read output of 'gh {}': {}", + args.join(" "), + e + )) + }); + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(AppError::auth(format!( + "'gh {}' timed out after {}s", + args.join(" "), + GH_COMMAND_TIMEOUT.as_secs() + ))); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => { + return Err(AppError::auth(format!( + "Failed to wait on 'gh {}': {}", + args.join(" "), + e + ))); + } + } + } +} /// Check if the GitHub CLI is installed. pub fn is_installed() -> bool { - Command::new("gh") - .arg("--version") - .output() + run_gh_with_timeout(&["--version"]) .map(|o| o.status.success()) .unwrap_or(false) } /// Check if the user is authenticated with the GitHub CLI. pub fn is_authenticated() -> bool { - Command::new("gh") - .args(["auth", "status"]) - .output() + run_gh_with_timeout(&["auth", "status"]) .map(|o| o.status.success()) .unwrap_or(false) } /// Get the authentication token from the GitHub CLI. pub fn get_token() -> Result { - let output = Command::new("gh") - .args(["auth", "token"]) - .output() - .map_err(|e| AppError::auth(format!("Failed to run 'gh auth token': {}", e)))?; + let output = run_gh_with_timeout(&["auth", "token"])?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -52,10 +96,7 @@ pub fn get_token() -> Result { /// Get the authenticated GitHub username. pub fn get_username() -> Result { - let output = Command::new("gh") - .args(["api", "user", "--jq", ".login"]) - .output() - .map_err(|e| AppError::auth(format!("Failed to get username from gh: {}", e)))?; + let output = run_gh_with_timeout(&["api", "user", "--jq", ".login"])?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -79,15 +120,7 @@ pub fn get_username() -> Result { /// Get token for a specific GitHub host (for GitHub Enterprise). pub fn get_token_for_host(host: &str) -> Result { - let output = Command::new("gh") - .args(["auth", "token", "--hostname", host]) - .output() - .map_err(|e| { - AppError::auth(format!( - "Failed to run 'gh auth token --hostname {}': {}", - host, e - )) - })?; + let output = run_gh_with_timeout(&["auth", "token", "--hostname", host])?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src/auth/gh_cli_tests.rs b/src/auth/gh_cli_tests.rs index 6d13e62..01944f4 100644 --- a/src/auth/gh_cli_tests.rs +++ b/src/auth/gh_cli_tests.rs @@ -1,5 +1,10 @@ use super::*; +#[test] +fn test_gh_command_timeout_is_ten_seconds() { + assert_eq!(GH_COMMAND_TIMEOUT.as_secs(), 10); +} + #[test] fn test_is_installed_returns_bool() { // This test just verifies the function runs without panicking diff --git a/src/setup/handler.rs b/src/setup/handler.rs index a13cea7..a94bfc6 100644 --- a/src/setup/handler.rs +++ b/src/setup/handler.rs @@ -192,15 +192,28 @@ async fn handle_auth(state: &mut SetupState, key: KeyEvent) { async fn do_authenticate(state: &mut SetupState) { let ws_provider = state.build_workspace_provider(); - match get_auth_for_provider(&ws_provider) { + // Auth shells out to `gh`; run it on the blocking pool so the TUI event + // loop keeps rendering instead of freezing on a stalled subprocess. + let result = tokio::task::spawn_blocking(move || match get_auth_for_provider(&ws_provider) { Ok(auth) => { let username = auth.username.or_else(|| gh_cli::get_username().ok()); + Ok((auth.token, username)) + } + Err(e) => Err(e.to_string()), + }) + .await; + + match result { + Ok(Ok((token, username))) => { state.username = username; - state.auth_token = Some(auth.token); + state.auth_token = Some(token); state.auth_status = AuthStatus::Success; } + Ok(Err(e)) => { + state.auth_status = AuthStatus::Failed(e); + } Err(e) => { - state.auth_status = AuthStatus::Failed(e.to_string()); + state.auth_status = AuthStatus::Failed(format!("Auth task failed: {}", e)); } } } diff --git a/src/workflows/sync_workspace.rs b/src/workflows/sync_workspace.rs index 53f1a30..ad940f4 100644 --- a/src/workflows/sync_workspace.rs +++ b/src/workflows/sync_workspace.rs @@ -63,8 +63,13 @@ pub async fn prepare_sync_workspace( request: SyncWorkspaceRequest<'_>, discovery_progress: &dyn DiscoveryProgress, ) -> Result { - // Authenticate and build provider - let auth = get_auth_for_provider(&request.workspace.provider)?; + // Authenticate and build provider. The auth path shells out to `gh`, which + // can stall on network or SSH issues; run it on the blocking pool so the + // async runtime stays responsive. + let provider_cfg = request.workspace.provider.clone(); + let auth = tokio::task::spawn_blocking(move || get_auth_for_provider(&provider_cfg)) + .await + .map_err(|e| AppError::auth(format!("Auth task failed: {}", e)))??; let provider = create_provider(&request.workspace.provider, &auth.token)?; // Build orchestrator from workspace + global config From ab05538b44a01b695e0d45e5db6707b00ea04cbe Mon Sep 17 00:00:00 2001 From: Manuel Date: Sun, 19 Apr 2026 16:28:02 +0200 Subject: [PATCH 04/89] Add timeouts to SSH probe and pagination --- src/auth/gh_cli.rs | 52 ++++-------------------------- src/auth/mod.rs | 1 + src/auth/process.rs | 53 +++++++++++++++++++++++++++++++ src/auth/process_tests.rs | 31 ++++++++++++++++++ src/auth/ssh.rs | 40 ++++++++++++++--------- src/provider/github/pagination.rs | 26 ++++++++++++--- 6 files changed, 137 insertions(+), 66 deletions(-) create mode 100644 src/auth/process.rs create mode 100644 src/auth/process_tests.rs diff --git a/src/auth/gh_cli.rs b/src/auth/gh_cli.rs index 5972094..80b72fd 100644 --- a/src/auth/gh_cli.rs +++ b/src/auth/gh_cli.rs @@ -2,58 +2,18 @@ //! //! Uses the `gh` CLI tool to obtain authentication tokens securely. +use crate::auth::process::run_with_timeout; use crate::errors::AppError; -use std::process::{Command, Output, Stdio}; -use std::time::{Duration, Instant}; +use std::process::Output; +use std::time::Duration; /// Maximum time to wait for any `gh` subprocess to complete. pub(crate) const GH_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); -/// Run a `gh` subcommand with a hard timeout, killing the child on expiry. -/// -/// Prevents the async runtime from being blocked indefinitely if `gh` stalls -/// (e.g. on network issues, interactive prompts, or a wedged SSH agent). +/// Run a `gh` subcommand with a hard timeout, mapping I/O errors to `AppError`. fn run_gh_with_timeout(args: &[&str]) -> Result { - let mut child = Command::new("gh") - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| AppError::auth(format!("Failed to spawn 'gh {}': {}", args.join(" "), e)))?; - - let deadline = Instant::now() + GH_COMMAND_TIMEOUT; - loop { - match child.try_wait() { - Ok(Some(_)) => { - return child.wait_with_output().map_err(|e| { - AppError::auth(format!( - "Failed to read output of 'gh {}': {}", - args.join(" "), - e - )) - }); - } - Ok(None) => { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - return Err(AppError::auth(format!( - "'gh {}' timed out after {}s", - args.join(" "), - GH_COMMAND_TIMEOUT.as_secs() - ))); - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(e) => { - return Err(AppError::auth(format!( - "Failed to wait on 'gh {}': {}", - args.join(" "), - e - ))); - } - } - } + run_with_timeout("gh", args, GH_COMMAND_TIMEOUT) + .map_err(|e| AppError::auth(format!("'gh {}' failed: {}", args.join(" "), e))) } /// Check if the GitHub CLI is installed. diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 4b01265..ed06f08 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -4,6 +4,7 @@ //! using the GitHub CLI (`gh auth token`). pub mod gh_cli; +pub(crate) mod process; pub mod ssh; use crate::config::WorkspaceProvider; diff --git a/src/auth/process.rs b/src/auth/process.rs new file mode 100644 index 0000000..e83c493 --- /dev/null +++ b/src/auth/process.rs @@ -0,0 +1,53 @@ +//! Subprocess helpers shared by auth probes. +//! +//! The `gh` CLI and the SSH probe both shell out to external binaries that +//! can stall on network or credential issues. This module provides a single +//! polling-based timeout helper so neither call can block the async runtime +//! or the TUI event loop indefinitely. + +use std::io; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +/// Run `program` with `args` and a hard wall-clock timeout. +/// +/// On timeout, the child process is killed and reaped, and an +/// [`io::ErrorKind::TimedOut`] error is returned. +pub(crate) fn run_with_timeout( + program: &str, + args: &[&str], + timeout: Duration, +) -> io::Result { + let mut child = Command::new(program) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let deadline = Instant::now() + timeout; + loop { + match child.try_wait()? { + Some(_) => return child.wait_with_output(), + None => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "'{} {}' timed out after {}s", + program, + args.join(" "), + timeout.as_secs() + ), + )); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + } +} + +#[cfg(test)] +#[path = "process_tests.rs"] +mod tests; diff --git a/src/auth/process_tests.rs b/src/auth/process_tests.rs new file mode 100644 index 0000000..821822a --- /dev/null +++ b/src/auth/process_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn returns_output_for_fast_command() { + let output = + run_with_timeout("true", &[], Duration::from_secs(2)).expect("fast command should succeed"); + assert!(output.status.success()); +} + +#[test] +fn returns_error_for_missing_binary() { + let err = run_with_timeout( + "definitely-not-a-real-binary-xyz", + &[], + Duration::from_secs(1), + ) + .expect_err("missing binary should fail"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); +} + +#[test] +fn times_out_and_kills_slow_command() { + let start = Instant::now(); + let err = run_with_timeout("sleep", &["5"], Duration::from_millis(200)) + .expect_err("slow command should time out"); + assert_eq!(err.kind(), io::ErrorKind::TimedOut); + assert!( + start.elapsed() < Duration::from_secs(2), + "timeout did not kill the child quickly enough" + ); +} diff --git a/src/auth/ssh.rs b/src/auth/ssh.rs index e6c8ba3..5be99fd 100644 --- a/src/auth/ssh.rs +++ b/src/auth/ssh.rs @@ -4,8 +4,17 @@ //! NOT GitHub API calls. This module detects if SSH keys are configured //! so we can provide better error messages and suggest SSH clone URLs. +use crate::auth::process::run_with_timeout; +use std::io; use std::path::PathBuf; -use std::process::Command; +use std::time::Duration; + +/// Maximum wall-clock time to wait for the SSH probe subprocess. +/// +/// The SSH `ConnectTimeout=5` option only guards the TCP handshake; the +/// authentication exchange that follows can still stall (e.g. on an +/// unresponsive agent). This is an outer safety net that kills the process. +pub(crate) const SSH_PROBE_TIMEOUT: Duration = Duration::from_secs(10); /// Outcome of probing SSH connectivity to GitHub. #[derive(Debug, Clone, PartialEq, Eq)] @@ -48,22 +57,23 @@ fn parse_ssh_probe_output(stderr: &str) -> SshProbeResult { /// Probe SSH connectivity to GitHub and return a diagnostic result. /// -/// Uses BatchMode to avoid interactive prompts. ConnectTimeout=5 prevents -/// hanging on network issues. +/// Uses BatchMode to avoid interactive prompts. `ConnectTimeout=5` guards +/// the TCP connect; [`SSH_PROBE_TIMEOUT`] is an outer wall-clock limit that +/// kills the process if the subsequent handshake stalls. pub fn probe_github_ssh() -> SshProbeResult { - let output = Command::new("ssh") - .args([ - "-T", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=5", - "git@github.com", - ]) - .output(); - - match output { + let args = [ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + "git@github.com", + ]; + + match run_with_timeout("ssh", &args, SSH_PROBE_TIMEOUT) { Ok(o) => parse_ssh_probe_output(&String::from_utf8_lossy(&o.stderr)), + Err(e) if e.kind() == io::ErrorKind::TimedOut => SshProbeResult::ConnectionTimeout, + Err(e) if e.kind() == io::ErrorKind::NotFound => SshProbeResult::SshNotFound, Err(_) => SshProbeResult::SshNotFound, } } diff --git a/src/provider/github/pagination.rs b/src/provider/github/pagination.rs index 52a831a..e7eb458 100644 --- a/src/provider/github/pagination.rs +++ b/src/provider/github/pagination.rs @@ -6,7 +6,7 @@ use reqwest::header::AUTHORIZATION; use reqwest::Client; use serde::de::DeserializeOwned; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::errors::ProviderError; @@ -19,6 +19,12 @@ const MAX_RETRIES: u32 = 3; /// Initial backoff in ms. Doubles each retry: 1s -> 2s -> 4s. const INITIAL_BACKOFF_MS: u64 = 1000; +/// Hard wall-clock budget for a full paginated fetch. +/// +/// Retries, rate-limit sleeps, and per-page requests all count against this +/// budget. Prevents discovery from running for an unbounded amount of time. +pub(crate) const PAGINATION_DEADLINE: Duration = Duration::from_secs(300); + /// Parses the GitHub Link header to find the next page URL. /// /// GitHub Link headers look like: @@ -89,6 +95,7 @@ pub async fn fetch_all_pages( token: &str, initial_url: &str, ) -> Result, ProviderError> { + let deadline = Instant::now() + PAGINATION_DEADLINE; let mut results = Vec::new(); let mut url = Some(format!( "{}{}per_page=100", @@ -103,6 +110,14 @@ pub async fn fetch_all_pages( let mut backoff_ms = INITIAL_BACKOFF_MS; let (next_url_opt, items) = loop { + if Instant::now() >= deadline { + return Err(ProviderError::Network(format!( + "Pagination exceeded {}s budget for '{}'", + PAGINATION_DEADLINE.as_secs(), + initial_url + ))); + } + let response = match client .get(¤t_url) .header(AUTHORIZATION, format!("Bearer {}", token)) @@ -131,12 +146,13 @@ pub async fn fetch_all_pages( .and_then(|h| h.to_str().ok()) .unwrap_or("unknown"); - // Try to parse reset time and wait + // Try to parse reset time and wait, but only if the + // reset window fits inside the remaining budget. if let Some(wait_time) = calculate_wait_time(reset) { - if retry_count < MAX_RETRIES { + let wait_with_buffer = wait_time + Duration::from_secs(5); + let fits_budget = Instant::now() + wait_with_buffer < deadline; + if retry_count < MAX_RETRIES && fits_budget { retry_count += 1; - // Add a small buffer to the wait time - let wait_with_buffer = wait_time + Duration::from_secs(5); tokio::time::sleep(wait_with_buffer).await; continue; // Retry the request } From 465e44c072a96537b687d5e2d626be3b9c606708 Mon Sep 17 00:00:00 2001 From: Manuel Date: Sun, 19 Apr 2026 16:31:02 +0200 Subject: [PATCH 05/89] Extract RepoScanService to separate backend API from frontend consumers Move scan_all_workspaces, scan_single_repo, and check_important_ignored_files out of src/commands/daemon.rs into a new src/api/service.rs as RepoScanService. The daemon loop, socket handlers, and the CLI `status` command now call the same service, eliminating duplicate scan logic and giving `status` access to the full FinderRepoStatus (branches, remotes, worktrees, badge). This establishes a clean backend/frontend separation: src/ is the API (Rust scanning + git logic), macos/ and future frontends are consumers of the JSON contract. Discovery's scan_local gains ?Sized bounds so it accepts &dyn GitOperations. Tests relocated from commands/daemon_tests.rs to api/service_tests.rs. All 522 tests pass; fmt and clippy clean. --- src/api/mod.rs | 18 ++ src/api/service.rs | 312 +++++++++++++++++++++++++++++++++++ src/api/service_tests.rs | 91 ++++++++++ src/commands/daemon.rs | 265 ++--------------------------- src/commands/daemon_tests.rs | 60 ------- src/commands/status.rs | 157 +++++++++--------- src/discovery.rs | 6 +- src/lib.rs | 1 + 8 files changed, 515 insertions(+), 395 deletions(-) create mode 100644 src/api/mod.rs create mode 100644 src/api/service.rs create mode 100644 src/api/service_tests.rs diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..ced91f0 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,18 @@ +//! Public API layer for repository status scanning. +//! +//! This module exposes the `RepoScanService` — the core service used by +//! the daemon, CLI, and any future frontend (HTTP server, native app) to +//! scan repositories and compute badge status. +//! +//! ## Architecture +//! +//! The service sits between: +//! - **Consumers** (daemon loop, CLI `status` command, socket REFRESH handler) +//! - **Implementations** (`GitOperations` trait for git, `Config` for workspace layout) +//! +//! Consumers hold a `&RepoScanService` and call `scan_all()`, `scan_workspace()`, +//! or `scan_repo()` to get structured `FinderStatus` / `FinderRepoStatus` values. + +pub mod service; + +pub use service::RepoScanService; diff --git a/src/api/service.rs b/src/api/service.rs new file mode 100644 index 0000000..9ffb6e4 --- /dev/null +++ b/src/api/service.rs @@ -0,0 +1,312 @@ +//! Repository scanning service. +//! +//! `RepoScanService` is the API for scanning repositories and computing badge +//! status. It owns no state — callers construct it with references to a git +//! backend and a config, then invoke `scan_all()`, `scan_workspace()`, or +//! `scan_repo()`. + +use crate::config::{Config, WorkspaceConfig, WorkspaceStore}; +use crate::discovery::DiscoveryOrchestrator; +use crate::errors::Result; +use crate::git::GitOperations; +use crate::types::finder_status::{ + compute_badge, matches_important_pattern, FinderBranchInfo, FinderRemoteInfo, FinderRepoStatus, + FinderStatus, FinderWorkspaceInfo, FinderWorktreeInfo, OrgFolderInfo, + DEFAULT_IMPORTANT_IGNORED_PATTERNS, +}; +use std::path::{Path, PathBuf}; +use tracing::debug; + +/// Service that scans repositories and computes badge status. +/// +/// This is the core API. The daemon, CLI, and any future frontend +/// (HTTP server, native app) use this to get repository status. +pub struct RepoScanService<'a> { + git: &'a dyn GitOperations, + config: &'a Config, +} + +impl<'a> RepoScanService<'a> { + /// Create a new service bound to a git backend and config. + pub fn new(git: &'a dyn GitOperations, config: &'a Config) -> Self { + Self { git, config } + } + + /// Scan all workspaces and build a complete `FinderStatus`. + /// + /// Used by: daemon loop, REFRESH_ALL socket command. + pub fn scan_all(&self, pid: u32) -> Result { + let timestamp = chrono::Utc::now().to_rfc3339(); + let mut status = FinderStatus::new(pid, timestamp); + + for ws_path in &self.config.workspaces { + let expanded = shellexpand::tilde(ws_path).to_string(); + let root = PathBuf::from(&expanded); + if !root.exists() { + debug!(path = %root.display(), "Workspace root does not exist, skipping"); + continue; + } + + // Load workspace config + let ws_config = match WorkspaceStore::load(&root) { + Ok(ws) => ws, + Err(e) => { + debug!( + path = %root.display(), + error = %e, + "Failed to load workspace config, skipping" + ); + continue; + } + }; + + let base_path = ws_config.expanded_base_path(); + // Use directory name as workspace name + let ws_name = base_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(ws_path) + .to_string(); + + // orgs is Vec directly + let org_names: Vec = ws_config.orgs.clone(); + + status.workspaces.push(FinderWorkspaceInfo { + name: ws_name.clone(), + root: base_path.clone(), + orgs: org_names.clone(), + }); + + // Add org folder entries — scan filesystem for org directories + // If orgs list is specified, use it; otherwise discover from directory listing + let org_dirs: Vec = if org_names.is_empty() { + std::fs::read_dir(&base_path) + .ok() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) + .filter(|e| { + e.file_name() + .to_str() + .map(|n| !n.starts_with('.')) + .unwrap_or(false) + }) + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default() + } else { + org_names.clone() + }; + + for org_name in &org_dirs { + let org_path = base_path.join(org_name); + if org_path.exists() { + status.org_folders.push(OrgFolderInfo { + path: org_path, + org: org_name.clone(), + workspace: ws_name.clone(), + }); + } + } + + // Scan local repos in this workspace + let repos = self.scan_workspace_repos(&ws_config, Some(&ws_name)); + status.repos.extend(repos); + } + + Ok(status) + } + + /// Scan a single workspace and return its repos with full `FinderRepoStatus`. + /// + /// Used by: CLI `status` command. + pub fn scan_workspace(&self, workspace: &WorkspaceConfig) -> Result> { + let base_path = workspace.expanded_base_path(); + let ws_name = base_path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()); + + Ok(self.scan_workspace_repos(workspace, ws_name.as_deref())) + } + + /// Internal: scan all repos discovered inside a single workspace. + fn scan_workspace_repos( + &self, + workspace: &WorkspaceConfig, + workspace_name: Option<&str>, + ) -> Vec { + let base_path = workspace.expanded_base_path(); + let structure = workspace + .structure + .as_deref() + .unwrap_or(&self.config.structure); + + let orchestrator = + DiscoveryOrchestrator::new(workspace.filters.clone(), structure.to_string()); + let local_repos = orchestrator.scan_local(&base_path, self.git); + + local_repos + .into_iter() + .map(|(repo_path, org, _name)| self.scan_repo(&repo_path, workspace_name, Some(&org))) + .collect() + } + + /// Scan a single repository and build its `FinderRepoStatus`. + /// + /// Used by: REFRESH /path socket command; internally by `scan_workspace_repos`. + pub fn scan_repo( + &self, + repo_path: &Path, + workspace: Option<&str>, + org: Option<&str>, + ) -> FinderRepoStatus { + let git = self.git; + + // Get basic status + let repo_status = git + .status(repo_path) + .unwrap_or_else(|_| crate::git::RepoStatus { + branch: "unknown".to_string(), + is_uncommitted: false, + ahead: 0, + behind: 0, + has_untracked: false, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + }); + + // Get branches + let branches: Vec = git + .list_branches(repo_path) + .unwrap_or_default() + .into_iter() + .map(|b| FinderBranchInfo { + name: b.name, + upstream: b.upstream, + ahead: b.ahead, + behind: b.behind, + synced: b.is_synced, + }) + .collect(); + + let all_branches_synced = branches.iter().all(|b| b.synced); + + // Get remotes + let remotes: Vec = git + .list_remotes(repo_path) + .unwrap_or_default() + .into_iter() + .map(|r| FinderRemoteInfo { + name: r.name, + url: r.fetch_url, + }) + .collect(); + + // Get worktrees + let worktree_infos = git.list_worktrees(repo_path).unwrap_or_default(); + let mut worktrees = Vec::new(); + let mut all_worktrees_synced = true; + + for wt in &worktree_infos { + // Skip the main worktree (same as repo_path) + if wt.path == repo_path { + continue; + } + // Check worktree status + let wt_synced = if wt.is_bare || wt.is_detached { + true + } else { + git.status(&wt.path) + .map(|s| s.is_clean_and_synced()) + .unwrap_or(false) + }; + if !wt_synced { + all_worktrees_synced = false; + } + worktrees.push(FinderWorktreeInfo { + path: wt.path.clone(), + branch: wt.branch.clone(), + synced: wt_synced, + }); + } + + // Get commit count + let commit_count = git.commit_count(repo_path).unwrap_or(0); + + // Get stash count + let stash_count = git.stash_count(repo_path).unwrap_or(0); + + // Check for important ignored files (only if otherwise clean) + let is_otherwise_clean = repo_status.staged_count == 0 + && repo_status.unstaged_count == 0 + && repo_status.untracked_count == 0 + && repo_status.ahead == 0 + && all_branches_synced + && all_worktrees_synced; + + let (has_important_ignored_files, important_ignored_files) = if is_otherwise_clean { + self.check_important_ignored(repo_path) + } else { + (false, Vec::new()) + }; + + // Compute badge + let badge = compute_badge( + repo_status.staged_count, + repo_status.unstaged_count, + repo_status.untracked_count, + repo_status.ahead, + all_branches_synced, + all_worktrees_synced, + has_important_ignored_files, + ); + + FinderRepoStatus { + path: repo_path.to_path_buf(), + workspace: workspace.map(|s| s.to_string()), + org: org.map(|s| s.to_string()), + badge, + current_branch: repo_status.branch, + default_branch: None, + commit_count, + staged_count: repo_status.staged_count, + unstaged_count: repo_status.unstaged_count, + untracked_count: repo_status.untracked_count, + ahead: repo_status.ahead, + behind: repo_status.behind, + stash_count, + has_important_ignored_files, + important_ignored_files, + branches, + all_branches_synced, + remotes, + worktrees, + all_worktrees_synced, + } + } + + /// Check if a repo has important ignored files matching the configured patterns. + fn check_important_ignored(&self, repo_path: &Path) -> (bool, Vec) { + let ignored_files = match self.git.list_ignored_files(repo_path) { + Ok(files) => files, + Err(_) => return (false, Vec::new()), + }; + + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + let important: Vec = ignored_files + .into_iter() + .filter(|f| matches_important_pattern(f, patterns)) + .collect(); + + let has_any = !important.is_empty(); + (has_any, important) + } +} + +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; diff --git a/src/api/service_tests.rs b/src/api/service_tests.rs new file mode 100644 index 0000000..a48a4f9 --- /dev/null +++ b/src/api/service_tests.rs @@ -0,0 +1,91 @@ +use super::*; +use crate::config::Config; +use crate::git::traits::mock::{MockConfig, MockGit}; +use crate::git::traits::RepoStatus; +use crate::types::finder_status::Badge; + +fn default_config() -> Config { + Config::default() +} + +#[test] +fn test_scan_repo_clean() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_repo(Path::new("/tmp/repo"), Some("ws"), Some("org")); + + assert_eq!(status.badge, Badge::Green); + assert_eq!(status.current_branch, "main"); + assert_eq!(status.staged_count, 0); + assert_eq!(status.unstaged_count, 0); + assert_eq!(status.workspace, Some("ws".to_string())); + assert_eq!(status.org, Some("org".to_string())); + assert!(status.all_branches_synced); +} + +#[test] +fn test_scan_repo_dirty() { + let mock_cfg = MockConfig { + default_status: RepoStatus { + branch: "feature".to_string(), + is_uncommitted: true, + ahead: 2, + behind: 0, + has_untracked: true, + staged_count: 1, + unstaged_count: 3, + untracked_count: 2, + }, + ..Default::default() + }; + let mock = MockGit::with_config(mock_cfg); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_repo(Path::new("/tmp/repo"), None, None); + + assert_eq!(status.badge, Badge::Red); + assert_eq!(status.current_branch, "feature"); + assert_eq!(status.staged_count, 1); + assert_eq!(status.unstaged_count, 3); + assert_eq!(status.untracked_count, 2); + assert_eq!(status.ahead, 2); +} + +#[test] +fn test_scan_repo_no_workspace() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_repo(Path::new("/tmp/repo"), None, None); + + assert!(status.workspace.is_none()); + assert!(status.org.is_none()); +} + +#[test] +fn test_check_important_ignored_none() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let (has, files) = service.check_important_ignored(Path::new("/tmp/repo")); + assert!(!has); + assert!(files.is_empty()); +} + +#[test] +fn test_scan_all_empty_workspaces() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_all(12345).unwrap(); + assert_eq!(status.daemon_pid, 12345); + assert!(status.workspaces.is_empty()); + assert!(status.repos.is_empty()); + assert!(status.org_folders.is_empty()); +} diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs index 1ed22ae..76c9a3b 100644 --- a/src/commands/daemon.rs +++ b/src/commands/daemon.rs @@ -3,19 +3,19 @@ //! Runs a background daemon that monitors workspace repositories, //! computes Finder badge status, and writes the status JSON file. //! Listens on a Unix socket for refresh requests from the Finder extension. +//! +//! All scanning logic lives in `crate::api::RepoScanService`. This module +//! is just the CLI surface (start/stop/status) plus the daemon loop and +//! socket handler that drive the service. +use crate::api::RepoScanService; use crate::cli::DaemonArgs; -use crate::config::{Config, WorkspaceStore}; -use crate::discovery::DiscoveryOrchestrator; +use crate::config::Config; use crate::errors::Result; -use crate::git::{GitOperations, ShellGit}; +use crate::git::ShellGit; use crate::ipc::{IpcConfig, StatusFileWriter}; use crate::output::Output; -use crate::types::finder_status::{ - compute_badge, matches_important_pattern, FinderBranchInfo, FinderRepoStatus, FinderStatus, - FinderWorkspaceInfo, FinderWorktreeInfo, OrgFolderInfo, DEFAULT_IMPORTANT_IGNORED_PATTERNS, -}; -use std::path::{Path, PathBuf}; +use std::path::Path; use tracing::{debug, error, info, warn}; /// Run the daemon command. @@ -39,10 +39,11 @@ pub async fn run(args: &DaemonArgs, config: &Config, output: &Output) -> Result< let status_writer = StatusFileWriter::new(ipc_config.status_file_path()); let git = ShellGit::new(); + let service = RepoScanService::new(&git, config); let pid = std::process::id(); // Initial scan - let finder_status = scan_all_workspaces(config, &git, pid)?; + let finder_status = service.scan_all(pid)?; status_writer.write(&finder_status)?; info!( repos = finder_status.repos.len(), @@ -69,7 +70,7 @@ pub async fn run(args: &DaemonArgs, config: &Config, output: &Output) -> Result< // Wait for the polling interval _ = tokio::time::sleep(interval) => { debug!("Polling interval reached, scanning..."); - match scan_all_workspaces(config, &git, pid) { + match service.scan_all(pid) { Ok(status) => { if let Err(e) = status_writer.write(&status) { error!(error = %e, "Failed to write status file"); @@ -136,6 +137,7 @@ async fn handle_socket_connection( let cmd = DaemonCommand::parse(&line); let git = ShellGit::new(); + let service = RepoScanService::new(&git, config); let response = match cmd { DaemonCommand::Ping => "PONG\n".to_string(), @@ -143,7 +145,7 @@ async fn handle_socket_connection( if let DaemonCommand::Refresh(ref path) = cmd { debug!(path = %path.display(), "Refresh requested"); } - match scan_all_workspaces(config, &git, pid) { + match service.scan_all(pid) { Ok(status) => { let file_writer = StatusFileWriter::new(status_path.to_path_buf()); let _ = file_writer.write(&status); @@ -170,247 +172,6 @@ async fn handle_socket_connection( let _ = writer.flush().await; } -/// Scan all configured workspaces and build the FinderStatus. -fn scan_all_workspaces(config: &Config, git: &ShellGit, pid: u32) -> Result { - let timestamp = chrono::Utc::now().to_rfc3339(); - let mut status = FinderStatus::new(pid, timestamp); - - for ws_path in &config.workspaces { - let expanded = shellexpand::tilde(ws_path).to_string(); - let root = PathBuf::from(&expanded); - if !root.exists() { - debug!(path = %root.display(), "Workspace root does not exist, skipping"); - continue; - } - - // Load workspace config - let ws_config = match WorkspaceStore::load(&root) { - Ok(ws) => ws, - Err(e) => { - debug!( - path = %root.display(), - error = %e, - "Failed to load workspace config, skipping" - ); - continue; - } - }; - - let base_path = ws_config.expanded_base_path(); - // Use directory name as workspace name - let ws_name = base_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(ws_path) - .to_string(); - let structure = ws_config.structure.as_deref().unwrap_or(&config.structure); - - // orgs is Vec directly - let org_names: Vec = ws_config.orgs.clone(); - - status.workspaces.push(FinderWorkspaceInfo { - name: ws_name.clone(), - root: base_path.clone(), - orgs: org_names.clone(), - }); - - // Add org folder entries — scan filesystem for org directories - // If orgs list is specified, use it; otherwise discover from directory listing - let org_dirs: Vec = if org_names.is_empty() { - // Discover org directories from filesystem - std::fs::read_dir(&base_path) - .ok() - .map(|entries| { - entries - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) - .filter(|e| { - e.file_name() - .to_str() - .map(|n| !n.starts_with('.')) - .unwrap_or(false) - }) - .filter_map(|e| e.file_name().into_string().ok()) - .collect() - }) - .unwrap_or_default() - } else { - org_names.clone() - }; - - for org_name in &org_dirs { - let org_path = base_path.join(org_name); - if org_path.exists() { - status.org_folders.push(OrgFolderInfo { - path: org_path, - org: org_name.clone(), - workspace: ws_name.clone(), - }); - } - } - - // Scan local repos - let orchestrator = - DiscoveryOrchestrator::new(ws_config.filters.clone(), structure.to_string()); - let local_repos = orchestrator.scan_local(&base_path, git); - - for (repo_path, org, _name) in local_repos { - let repo_status = scan_single_repo(git, &repo_path, Some(&ws_name), Some(&org)); - status.repos.push(repo_status); - } - } - - Ok(status) -} - -/// Scan a single repository and build its FinderRepoStatus. -fn scan_single_repo( - git: &dyn GitOperations, - repo_path: &Path, - workspace: Option<&str>, - org: Option<&str>, -) -> FinderRepoStatus { - // Get basic status - let repo_status = git - .status(repo_path) - .unwrap_or_else(|_| crate::git::RepoStatus { - branch: "unknown".to_string(), - is_uncommitted: false, - ahead: 0, - behind: 0, - has_untracked: false, - staged_count: 0, - unstaged_count: 0, - untracked_count: 0, - }); - - // Get branches - let branches: Vec = git - .list_branches(repo_path) - .unwrap_or_default() - .into_iter() - .map(|b| FinderBranchInfo { - name: b.name, - upstream: b.upstream, - ahead: b.ahead, - behind: b.behind, - synced: b.is_synced, - }) - .collect(); - - let all_branches_synced = branches.iter().all(|b| b.synced); - - // Get remotes - let remotes: Vec = git - .list_remotes(repo_path) - .unwrap_or_default() - .into_iter() - .map(|r| crate::types::finder_status::FinderRemoteInfo { - name: r.name, - url: r.fetch_url, - }) - .collect(); - - // Get worktrees - let worktree_infos = git.list_worktrees(repo_path).unwrap_or_default(); - let mut worktrees = Vec::new(); - let mut all_worktrees_synced = true; - - for wt in &worktree_infos { - // Skip the main worktree (same as repo_path) - if wt.path == repo_path { - continue; - } - // Check worktree status - let wt_synced = if wt.is_bare || wt.is_detached { - true - } else { - git.status(&wt.path) - .map(|s| s.is_clean_and_synced()) - .unwrap_or(false) - }; - if !wt_synced { - all_worktrees_synced = false; - } - worktrees.push(FinderWorktreeInfo { - path: wt.path.clone(), - branch: wt.branch.clone(), - synced: wt_synced, - }); - } - - // Get commit count - let commit_count = git.commit_count(repo_path).unwrap_or(0); - - // Get stash count - let stash_count = git.stash_count(repo_path).unwrap_or(0); - - // Check for important ignored files (only if otherwise clean) - let is_otherwise_clean = repo_status.staged_count == 0 - && repo_status.unstaged_count == 0 - && repo_status.untracked_count == 0 - && repo_status.ahead == 0 - && all_branches_synced - && all_worktrees_synced; - - let (has_important_ignored_files, important_ignored_files) = if is_otherwise_clean { - check_important_ignored_files(git, repo_path) - } else { - (false, Vec::new()) - }; - - // Compute badge - let badge = compute_badge( - repo_status.staged_count, - repo_status.unstaged_count, - repo_status.untracked_count, - repo_status.ahead, - all_branches_synced, - all_worktrees_synced, - has_important_ignored_files, - ); - - FinderRepoStatus { - path: repo_path.to_path_buf(), - workspace: workspace.map(|s| s.to_string()), - org: org.map(|s| s.to_string()), - badge, - current_branch: repo_status.branch, - default_branch: None, // Could be determined from remote HEAD - commit_count, - staged_count: repo_status.staged_count, - unstaged_count: repo_status.unstaged_count, - untracked_count: repo_status.untracked_count, - ahead: repo_status.ahead, - behind: repo_status.behind, - stash_count, - has_important_ignored_files, - important_ignored_files, - branches, - all_branches_synced, - remotes, - worktrees, - all_worktrees_synced, - } -} - -/// Check if a repo has important ignored files matching the configured patterns. -fn check_important_ignored_files(git: &dyn GitOperations, repo_path: &Path) -> (bool, Vec) { - let ignored_files = match git.list_ignored_files(repo_path) { - Ok(files) => files, - Err(_) => return (false, Vec::new()), - }; - - let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; - let important: Vec = ignored_files - .into_iter() - .filter(|f| matches_important_pattern(f, patterns)) - .collect(); - - let has_any = !important.is_empty(); - (has_any, important) -} - /// Show daemon status. fn show_status(ipc_config: &IpcConfig, output: &Output) -> Result<()> { let status_path = ipc_config.status_file_path(); diff --git a/src/commands/daemon_tests.rs b/src/commands/daemon_tests.rs index 32c703a..f4ad3a8 100644 --- a/src/commands/daemon_tests.rs +++ b/src/commands/daemon_tests.rs @@ -1,56 +1,4 @@ use super::*; -use crate::git::traits::mock::{MockConfig, MockGit}; -use crate::git::traits::RepoStatus; -use crate::types::finder_status::Badge; - -#[test] -fn test_scan_single_repo_clean() { - let mock = MockGit::new(); - let status = scan_single_repo(&mock, Path::new("/tmp/repo"), Some("ws"), Some("org")); - - assert_eq!(status.badge, Badge::Green); - assert_eq!(status.current_branch, "main"); - assert_eq!(status.staged_count, 0); - assert_eq!(status.unstaged_count, 0); - assert_eq!(status.workspace, Some("ws".to_string())); - assert_eq!(status.org, Some("org".to_string())); - assert!(status.all_branches_synced); -} - -#[test] -fn test_scan_single_repo_dirty() { - let config = MockConfig { - default_status: RepoStatus { - branch: "feature".to_string(), - is_uncommitted: true, - ahead: 2, - behind: 0, - has_untracked: true, - staged_count: 1, - unstaged_count: 3, - untracked_count: 2, - }, - ..Default::default() - }; - let mock = MockGit::with_config(config); - let status = scan_single_repo(&mock, Path::new("/tmp/repo"), None, None); - - assert_eq!(status.badge, Badge::Red); - assert_eq!(status.current_branch, "feature"); - assert_eq!(status.staged_count, 1); - assert_eq!(status.unstaged_count, 3); - assert_eq!(status.untracked_count, 2); - assert_eq!(status.ahead, 2); -} - -#[test] -fn test_scan_single_repo_no_workspace() { - let mock = MockGit::new(); - let status = scan_single_repo(&mock, Path::new("/tmp/repo"), None, None); - - assert!(status.workspace.is_none()); - assert!(status.org.is_none()); -} #[test] fn test_is_process_alive_self() { @@ -63,11 +11,3 @@ fn test_is_process_alive_nonexistent() { // PID 99999 is very unlikely to exist assert!(!is_process_alive(99999)); } - -#[test] -fn test_check_important_ignored_files_none() { - let mock = MockGit::new(); - let (has, files) = check_important_ignored_files(&mock, Path::new("/tmp/repo")); - assert!(!has); - assert!(files.is_empty()); -} diff --git a/src/commands/status.rs b/src/commands/status.rs index 2c19026..641e867 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -1,10 +1,10 @@ //! Status command handler. +use crate::api::RepoScanService; use crate::cli::StatusArgs; use crate::config::{Config, WorkspaceManager}; -use crate::discovery::DiscoveryOrchestrator; use crate::errors::Result; -use crate::git::{GitOperations, ShellGit}; +use crate::git::ShellGit; use crate::output::{format_count, Output}; /// Show status of repositories. @@ -13,89 +13,92 @@ pub async fn run(args: &StatusArgs, config: &Config, output: &Output) -> Result< // Ensure base path exists (offer to fix if user moved it) super::ensure_base_path(&workspace, output)?; - let base_path = workspace.expanded_base_path(); - let structure = workspace.structure.as_deref().unwrap_or(&config.structure); - - // Scan local repositories + // Use the scan service to get full FinderRepoStatus for every repo let git = ShellGit::new(); - let orchestrator = DiscoveryOrchestrator::new(workspace.filters.clone(), structure.to_string()); - let local_repos = orchestrator.scan_local(&base_path, &git); + let service = RepoScanService::new(&git, config); + let repos = service.scan_workspace(&workspace)?; - if local_repos.is_empty() { + if repos.is_empty() { output.warn("No repositories found"); return Ok(()); } - output.info(&format_count(local_repos.len(), "repositories found")); + output.info(&format_count(repos.len(), "repositories found")); // Get status for each let mut uncommitted_count = 0; let mut behind_count = 0; - let mut error_count = 0; - - for (path, org, name) in &local_repos { - let status = git.status(path); - - match status { - Ok(s) => { - let is_uncommitted = s.is_uncommitted || s.has_untracked; - let is_behind = s.behind > 0; - - // Apply filters - if args.uncommitted && !is_uncommitted { - continue; - } - if args.behind && !is_behind { - continue; - } - if !args.org.is_empty() && !args.org.contains(org) { - continue; - } - - if is_uncommitted { - uncommitted_count += 1; - } - if is_behind { - behind_count += 1; - } - - // Print status - let full_name = format!("{}/{}", org, name); - if args.detailed { - println!("{}", full_name); - println!(" Branch: {}", s.branch); - if s.ahead > 0 || s.behind > 0 { - println!(" Ahead: {}, Behind: {}", s.ahead, s.behind); - } - if s.is_uncommitted { - println!(" Status: uncommitted changes"); - } - if s.has_untracked { - println!(" Status: has untracked files"); - } - } else { - let mut indicators = Vec::new(); - if is_uncommitted { - indicators.push("*".to_string()); - } - if s.ahead > 0 { - indicators.push(format!("+{}", s.ahead)); - } - if s.behind > 0 { - indicators.push(format!("-{}", s.behind)); - } - - if indicators.is_empty() { - println!(" {} (clean)", full_name); - } else { - println!(" {} [{}]", full_name, indicators.join(", ")); - } - } + + for repo in &repos { + let is_uncommitted = + repo.staged_count > 0 || repo.unstaged_count > 0 || repo.untracked_count > 0; + let is_behind = repo.behind > 0; + + // Apply filters + if args.uncommitted && !is_uncommitted { + continue; + } + if args.behind && !is_behind { + continue; + } + if !args.org.is_empty() { + let matches_org = repo + .org + .as_ref() + .map(|o| args.org.contains(o)) + .unwrap_or(false); + if !matches_org { + continue; + } + } + + if is_uncommitted { + uncommitted_count += 1; + } + if is_behind { + behind_count += 1; + } + + // Build display name: "org/name" or just the path's last segment + let name = repo + .path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?"); + let full_name = match &repo.org { + Some(org) => format!("{}/{}", org, name), + None => name.to_string(), + }; + + if args.detailed { + println!("{}", full_name); + println!(" Branch: {}", repo.current_branch); + if repo.ahead > 0 || repo.behind > 0 { + println!(" Ahead: {}, Behind: {}", repo.ahead, repo.behind); + } + if repo.staged_count > 0 || repo.unstaged_count > 0 { + println!(" Status: uncommitted changes"); + } + if repo.untracked_count > 0 { + println!(" Status: has untracked files"); + } + } else { + let mut indicators = Vec::new(); + if is_uncommitted { + indicators.push("*".to_string()); + } + if repo.ahead > 0 { + indicators.push(format!("+{}", repo.ahead)); } - Err(e) => { - error_count += 1; - output.verbose(&format!(" {}/{} - error: {}", org, name, e)); + if repo.behind > 0 { + indicators.push(format!("-{}", repo.behind)); + } + + if indicators.is_empty() { + println!(" {} (clean)", full_name); + } else { + println!(" {} [{}]", full_name, indicators.join(", ")); } } } @@ -113,13 +116,7 @@ pub async fn run(args: &StatusArgs, config: &Config, output: &Output) -> Result< "{} repositories are behind upstream", behind_count )); - } - if error_count > 0 { - output.warn(&format!( - "{} repositories could not be checked", - error_count - )); - } else if uncommitted_count == 0 && behind_count == 0 { + } else if uncommitted_count == 0 { output.success("All repositories are clean and up to date"); } diff --git a/src/discovery.rs b/src/discovery.rs index e226464..80132c5 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -13,7 +13,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; /// Mutable context for directory scanning (keeps `scan_dir` under Clippy’s argument limit). -struct ScanDirContext<'a, G: GitOperations> { +struct ScanDirContext<'a, G: GitOperations + ?Sized> { base_path: &'a Path, git: &'a G, repos: &'a mut Vec<(PathBuf, String, String)>, @@ -137,7 +137,7 @@ impl DiscoveryOrchestrator { } /// Scans local filesystem for cloned repositories. - pub fn scan_local( + pub fn scan_local( &self, base_path: &Path, git: &G, @@ -164,7 +164,7 @@ impl DiscoveryOrchestrator { } /// Recursively scans directories for git repos. - fn scan_dir( + fn scan_dir( &self, path: &Path, current_depth: usize, diff --git a/src/lib.rs b/src/lib.rs index 0425bfd..01145dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,7 @@ //! git same sync //! ``` +pub mod api; pub mod app; pub mod auth; pub mod banner; From 5b20203e8c903a85380009bda56068c306b24a72 Mon Sep 17 00:00:00 2001 From: Manuel Date: Sun, 19 Apr 2026 17:18:49 +0200 Subject: [PATCH 06/89] Harden daemon shutdown and scaffold Xcode project for Finder badge testing Daemon: --status and --stop now print to stdout directly instead of through Output, which is Quiet by default and was suppressing them. Add a SIGTERM handler alongside SIGINT so `daemon --stop` triggers the socket cleanup branch. macOS: add XcodeGen spec (macos/project.yml), sandbox entitlements for host app and FinderSync extension with a temporary-exception read of ~/.config/git-same/finder/, move StatusReader to Shared so the extension target can reach it, and implement the refreshStatus and openInTerminal @objc actions that ContextMenuBuilder references. Generated .xcodeproj is gitignored. --- .gitignore | 6 ++ macos/GitSameBadge/GitSameBadge.entitlements | 14 ++++ macos/GitSameBadge/Info.plist | 34 +++++---- macos/GitSameBadgeSync/FinderSync.swift | 15 ++++ .../GitSameBadgeSync.entitlements | 14 ++++ macos/GitSameBadgeSync/Info.plist | 44 ++++++----- .../Services => Shared}/StatusReader.swift | 0 macos/project.yml | 76 +++++++++++++++++++ src/commands/daemon.rs | 52 ++++++++----- 9 files changed, 201 insertions(+), 54 deletions(-) create mode 100644 macos/GitSameBadge/GitSameBadge.entitlements create mode 100644 macos/GitSameBadgeSync/GitSameBadgeSync.entitlements rename macos/{GitSameBadge/Services => Shared}/StatusReader.swift (100%) create mode 100644 macos/project.yml diff --git a/.gitignore b/.gitignore index a08ad33..17d5487 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ cobertura.xml # Claude Code .claude/settings.local.json + +# Xcode (macOS FinderSync): project is generated by xcodegen from macos/project.yml +macos/*.xcodeproj/ +macos/*.xcworkspace/ +macos/build/ +macos/DerivedData/ diff --git a/macos/GitSameBadge/GitSameBadge.entitlements b/macos/GitSameBadge/GitSameBadge.entitlements new file mode 100644 index 0000000..daa497e --- /dev/null +++ b/macos/GitSameBadge/GitSameBadge.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.temporary-exception.files.home-relative-path.read-only + + /.config/git-same/finder/ + + + diff --git a/macos/GitSameBadge/Info.plist b/macos/GitSameBadge/Info.plist index f1e6c10..eb8c6f7 100644 --- a/macos/GitSameBadge/Info.plist +++ b/macos/GitSameBadge/Info.plist @@ -2,19 +2,25 @@ - CFBundleDisplayName - GitSameBadge - CFBundleIdentifier - com.zaai.git-same.GitSameBadge - CFBundleName - GitSameBadge - CFBundleVersion - 1 - CFBundleShortVersionString - 1.0 - CFBundlePackageType - APPL - LSMinimumSystemVersion - 13.0 + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + GitSameBadge + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + com.zaai.git-same.GitSameBadge + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + GitSameBadge + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index 6d7d88b..7fcce1d 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -97,4 +97,19 @@ class FinderSync: FIFinderSync { return NSMenu() } + + // MARK: - Context Menu Actions + + @objc func refreshStatus(_ sender: Any?) { + socketClient.send("REFRESH_ALL") { _ in } + } + + @objc func openInTerminal(_ sender: Any?) { + guard let targetURL = FIFinderSyncController.default().targetedURL() else { return } + NSWorkspace.shared.open( + [targetURL], + withApplicationAt: URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"), + configuration: NSWorkspace.OpenConfiguration() + ) + } } diff --git a/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements b/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements new file mode 100644 index 0000000..daa497e --- /dev/null +++ b/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.temporary-exception.files.home-relative-path.read-only + + /.config/git-same/finder/ + + + diff --git a/macos/GitSameBadgeSync/Info.plist b/macos/GitSameBadgeSync/Info.plist index bc642dd..c20593f 100644 --- a/macos/GitSameBadgeSync/Info.plist +++ b/macos/GitSameBadgeSync/Info.plist @@ -2,24 +2,30 @@ - CFBundleDisplayName - GitSameBadge Sync - CFBundleIdentifier - com.zaai.git-same.GitSameBadge.FinderSync - CFBundleName - GitSameBadgeSync - CFBundleVersion - 1 - CFBundleShortVersionString - 1.0 - CFBundlePackageType - XPC! - NSExtension - - NSExtensionPointIdentifier - com.apple.FinderSync - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).FinderSync - + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + GitSameBadge Sync + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + com.zaai.git-same.GitSameBadge.FinderSync + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + GitSameBadgeSync + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.FinderSync + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).FinderSync + diff --git a/macos/GitSameBadge/Services/StatusReader.swift b/macos/Shared/StatusReader.swift similarity index 100% rename from macos/GitSameBadge/Services/StatusReader.swift rename to macos/Shared/StatusReader.swift diff --git a/macos/project.yml b/macos/project.yml new file mode 100644 index 0000000..7697b90 --- /dev/null +++ b/macos/project.yml @@ -0,0 +1,76 @@ +name: GitSameBadge + +options: + bundleIdPrefix: com.zaai.git-same + createIntermediateGroups: true + deploymentTarget: + macOS: "13.0" + xcodeVersion: "15.0" + +settings: + base: + SWIFT_VERSION: "5.9" + MACOSX_DEPLOYMENT_TARGET: "13.0" + CODE_SIGN_STYLE: Automatic + # DEVELOPMENT_TEAM is intentionally blank: set it in Xcode + # Signing & Capabilities, or via `xcodebuild DEVELOPMENT_TEAM=XXXXXXXXXX`. + DEVELOPMENT_TEAM: "" + ENABLE_HARDENED_RUNTIME: YES + PRODUCT_NAME: $(TARGET_NAME) + +targets: + GitSameBadge: + type: application + platform: macOS + sources: + - path: GitSameBadge + - path: Shared + info: + path: GitSameBadge/Info.plist + properties: + CFBundleDisplayName: GitSameBadge + CFBundleIdentifier: com.zaai.git-same.GitSameBadge + CFBundleName: GitSameBadge + CFBundleVersion: "1" + CFBundleShortVersionString: "1.0" + CFBundlePackageType: APPL + LSMinimumSystemVersion: "13.0" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.zaai.git-same.GitSameBadge + CODE_SIGN_ENTITLEMENTS: GitSameBadge/GitSameBadge.entitlements + LD_RUNPATH_SEARCH_PATHS: + - "$(inherited)" + - "@executable_path/../Frameworks" + - "@executable_path/../PlugIns" + SWIFT_OBJC_BRIDGING_HEADER: "" + dependencies: + - target: GitSameBadgeSync + + GitSameBadgeSync: + type: app-extension + platform: macOS + sources: + - path: GitSameBadgeSync + - path: Shared + info: + path: GitSameBadgeSync/Info.plist + properties: + CFBundleDisplayName: GitSameBadge Sync + CFBundleIdentifier: com.zaai.git-same.GitSameBadge.FinderSync + CFBundleName: GitSameBadgeSync + CFBundleVersion: "1" + CFBundleShortVersionString: "1.0" + CFBundlePackageType: XPC! + NSExtension: + NSExtensionPointIdentifier: com.apple.FinderSync + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).FinderSync + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.zaai.git-same.GitSameBadge.FinderSync + CODE_SIGN_ENTITLEMENTS: GitSameBadgeSync/GitSameBadgeSync.entitlements + LD_RUNPATH_SEARCH_PATHS: + - "$(inherited)" + - "@executable_path/../Frameworks" + - "@executable_path/../../../../Frameworks" + SKIP_INSTALL: YES diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs index 76c9a3b..4b4cc2e 100644 --- a/src/commands/daemon.rs +++ b/src/commands/daemon.rs @@ -65,6 +65,11 @@ pub async fn run(args: &DaemonArgs, config: &Config, output: &Output) -> Result< // Main daemon loop let interval = tokio::time::Duration::from_secs(args.interval); + // Listen for SIGTERM in addition to SIGINT so `gisa daemon --stop` + // (which sends SIGTERM) triggers clean socket cleanup. + #[cfg(unix)] + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + loop { tokio::select! { // Wait for the polling interval @@ -98,9 +103,15 @@ pub async fn run(args: &DaemonArgs, config: &Config, output: &Output) -> Result< } } }, - // Handle shutdown signal + // Handle shutdown signals (SIGINT from ctrl-c, SIGTERM from `daemon --stop`) _ = tokio::signal::ctrl_c() => { - info!("Received shutdown signal"); + info!("Received SIGINT"); + output.info("Daemon shutting down..."); + socket_listener.cleanup(); + break; + }, + _ = sigterm.recv() => { + info!("Received SIGTERM"); output.info("Daemon shutting down..."); socket_listener.cleanup(); break; @@ -173,28 +184,28 @@ async fn handle_socket_connection( } /// Show daemon status. -fn show_status(ipc_config: &IpcConfig, output: &Output) -> Result<()> { +/// +/// User-facing diagnostic output is printed directly so it is not suppressed +/// by the default Quiet verbosity: `--status` must always answer. +fn show_status(ipc_config: &IpcConfig, _output: &Output) -> Result<()> { let status_path = ipc_config.status_file_path(); if !status_path.exists() { - output.info("Daemon is not running (no status file found)"); + println!("Daemon is not running (no status file found)"); return Ok(()); } let writer = StatusFileWriter::new(status_path); match writer.read() { Ok(status) => { - // Check if the PID is still alive let pid = status.daemon_pid; - let is_alive = is_process_alive(pid); - - if is_alive { - output.info(&format!("Daemon is running (PID: {})", pid)); + if is_process_alive(pid) { + println!("Daemon is running (PID: {})", pid); } else { - output.info(&format!("Daemon is not running (stale PID: {})", pid)); + println!("Daemon is not running (stale PID: {})", pid); } - output.info(&format!("Last scan: {}", status.timestamp)); - output.info(&format!("Repos monitored: {}", status.repos.len())); - output.info(&format!( + println!("Last scan: {}", status.timestamp); + println!("Repos monitored: {}", status.repos.len()); + println!( "Workspaces: {}", status .workspaces @@ -202,20 +213,20 @@ fn show_status(ipc_config: &IpcConfig, output: &Output) -> Result<()> { .map(|w| w.name.as_str()) .collect::>() .join(", ") - )); + ); } Err(e) => { - output.warn(&format!("Could not read status file: {}", e)); + eprintln!("Could not read status file: {}", e); } } Ok(()) } /// Stop a running daemon. -fn stop_daemon(ipc_config: &IpcConfig, output: &Output) -> Result<()> { +fn stop_daemon(ipc_config: &IpcConfig, _output: &Output) -> Result<()> { let status_path = ipc_config.status_file_path(); if !status_path.exists() { - output.info("No daemon is running"); + println!("No daemon is running"); return Ok(()); } @@ -224,19 +235,18 @@ fn stop_daemon(ipc_config: &IpcConfig, output: &Output) -> Result<()> { Ok(status) => { let pid = status.daemon_pid; if is_process_alive(pid) { - // Send SIGTERM via kill command let _ = std::process::Command::new("kill") .args(["-TERM", &pid.to_string()]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); - output.info(&format!("Sent stop signal to daemon (PID: {})", pid)); + println!("Sent stop signal to daemon (PID: {})", pid); } else { - output.info("Daemon is not running (stale status file)"); + println!("Daemon is not running (stale status file)"); } } Err(_) => { - output.info("Could not read daemon status"); + println!("Could not read daemon status"); } } Ok(()) From d4fc44336478709c5ee22db89b5cd92e5834d61a Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 00:45:04 +0200 Subject: [PATCH 07/89] Harden FinderSync extension sandbox and drawing for macOS 26 testing Switch to absolute-path temp-exception entitlement with real-path coverage for the config dir and the current workspace roots; the prior home-relative-path entitlement resolved against the sandbox container, not real $HOME. Resolve real $HOME in Shared/Constants.swift via getpwuid(getuid()) to bypass the sandbox's homeDirectoryForCurrentUser redirect, so status.json and finder.sock paths point at the daemon's real files. Re-enable App Sandbox on both targets (extensions are required to be sandboxed on macOS 26), revert the FinderSync class rename from an earlier debugging attempt, and rewrite BadgeManager to use NSImage(size:flipped:drawingHandler:) so badge images have valid CGImage-backed pixel data in a sandboxed extension. Note: badges still do not render on macOS 26.4, matching observed behavior for Synology's FinderSync extension. Likely system-level regression; revisit or migrate to NSFileProviderExtension later. --- macos/GitSameBadge/GitSameBadge.entitlements | 16 +-- macos/GitSameBadgeSync/BadgeManager.swift | 115 ++++++++---------- macos/GitSameBadgeSync/FinderSync.swift | 30 +++-- .../GitSameBadgeSync.entitlements | 16 +-- macos/Shared/Constants.swift | 15 ++- 5 files changed, 102 insertions(+), 90 deletions(-) diff --git a/macos/GitSameBadge/GitSameBadge.entitlements b/macos/GitSameBadge/GitSameBadge.entitlements index daa497e..807b075 100644 --- a/macos/GitSameBadge/GitSameBadge.entitlements +++ b/macos/GitSameBadge/GitSameBadge.entitlements @@ -2,13 +2,13 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - com.apple.security.temporary-exception.files.home-relative-path.read-only - - /.config/git-same/finder/ - + com.apple.security.app-sandbox + + com.apple.security.temporary-exception.files.absolute-path.read-only + + /Users/m/.config/git-same/finder/ + /Users/m/Manuel-Sun/Engineering/Same-GitHub/ + /Users/m/Manuel-Sun/Engineering/Same-SX/ + diff --git a/macos/GitSameBadgeSync/BadgeManager.swift b/macos/GitSameBadgeSync/BadgeManager.swift index 8a13a7b..954ff2c 100644 --- a/macos/GitSameBadgeSync/BadgeManager.swift +++ b/macos/GitSameBadgeSync/BadgeManager.swift @@ -10,73 +10,66 @@ enum BadgeManager { static func registerBadges() { let controller = FIFinderSyncController.default() - // Green badge: fully synced, safe to delete - if let greenImage = createBadgeImage(color: .systemGreen) { - controller.setBadgeImage(greenImage, label: "Synced", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.green) - } - - // Blue badge: synced but has important ignored files - if let blueImage = createBadgeImage(color: .systemBlue) { - controller.setBadgeImage(blueImage, label: "Has Local Config", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.blue) - } - - // Orange badge: main synced, worktrees/branches diverge - if let orangeImage = createBadgeImage(color: .systemOrange) { - controller.setBadgeImage(orangeImage, label: "Partially Synced", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.orange) - } - - // Red badge: uncommitted changes or unpushed commits - if let redImage = createBadgeImage(color: .systemRed) { - controller.setBadgeImage(redImage, label: "Uncommitted Changes", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.red) - } - - // Org folder badge - if let orgImage = createOrgBadgeImage() { - controller.setBadgeImage(orgImage, label: "Organization", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.org) - } + controller.setBadgeImage( + dotImage(color: .systemGreen), + label: "Synced", + forBadgeIdentifier: GitSameBadgeConstants.BadgeID.green + ) + controller.setBadgeImage( + dotImage(color: .systemBlue), + label: "Has Local Config", + forBadgeIdentifier: GitSameBadgeConstants.BadgeID.blue + ) + controller.setBadgeImage( + dotImage(color: .systemOrange), + label: "Partially Synced", + forBadgeIdentifier: GitSameBadgeConstants.BadgeID.orange + ) + controller.setBadgeImage( + dotImage(color: .systemRed), + label: "Uncommitted Changes", + forBadgeIdentifier: GitSameBadgeConstants.BadgeID.red + ) + controller.setBadgeImage( + orgImage(), + label: "Organization", + forBadgeIdentifier: GitSameBadgeConstants.BadgeID.org + ) } - /// Create a colored dot badge image. - private static func createBadgeImage(color: NSColor) -> NSImage? { + /// Colored dot badge, drawn via closure so it works without a + /// display context (lockFocus produces zero-pixel layer data in + /// sandboxed extensions). + private static func dotImage(color: NSColor) -> NSImage { let size = NSSize(width: 16, height: 16) - let image = NSImage(size: size) - image.lockFocus() - - // Draw a filled circle - let rect = NSRect(x: 2, y: 2, width: 12, height: 12) - let path = NSBezierPath(ovalIn: rect) - color.setFill() - path.fill() - - // Draw a thin border - NSColor.white.withAlphaComponent(0.5).setStroke() - path.lineWidth = 1.0 - path.stroke() - - image.unlockFocus() - return image + return NSImage(size: size, flipped: false) { rect in + let circle = NSBezierPath(ovalIn: NSRect(x: 2, y: 2, width: 12, height: 12)) + color.setFill() + circle.fill() + NSColor.white.withAlphaComponent(0.5).setStroke() + circle.lineWidth = 1.0 + circle.stroke() + return true + } } - /// Create an org folder badge image. - private static func createOrgBadgeImage() -> NSImage? { + /// Org-folder badge: purple building silhouette with four windows. + private static func orgImage() -> NSImage { let size = NSSize(width: 16, height: 16) - let image = NSImage(size: size) - image.lockFocus() - - // Draw a building/org icon using a simple shape - let rect = NSRect(x: 3, y: 2, width: 10, height: 12) - let path = NSBezierPath(roundedRect: rect, xRadius: 1, yRadius: 1) - NSColor.systemPurple.setFill() - path.fill() + return NSImage(size: size, flipped: false) { rect in + let body = NSBezierPath( + roundedRect: NSRect(x: 3, y: 2, width: 10, height: 12), + xRadius: 1, + yRadius: 1 + ) + NSColor.systemPurple.setFill() + body.fill() - // Draw windows - NSColor.white.setFill() - NSBezierPath(rect: NSRect(x: 5, y: 9, width: 2, height: 2)).fill() - NSBezierPath(rect: NSRect(x: 9, y: 9, width: 2, height: 2)).fill() - NSBezierPath(rect: NSRect(x: 5, y: 5, width: 2, height: 2)).fill() - NSBezierPath(rect: NSRect(x: 9, y: 5, width: 2, height: 2)).fill() - - image.unlockFocus() - return image + NSColor.white.setFill() + for (x, y) in [(5, 9), (9, 9), (5, 5), (9, 5)] { + NSBezierPath(rect: NSRect(x: x, y: y, width: 2, height: 2)).fill() + } + return true + } } } diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index 7fcce1d..fc9b441 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -3,6 +3,9 @@ import Cocoa import FinderSync +import os + +private let gsbLog = OSLog(subsystem: "com.zaai.git-same.GitSameBadge.FinderSync", category: "ext") class FinderSync: FIFinderSync { @@ -11,43 +14,53 @@ class FinderSync: FIFinderSync { override init() { super.init() + os_log("FinderSync init entered", log: gsbLog, type: .default) + NSLog("GitSameBadge: FinderSync init") - // Register badge images BadgeManager.registerBadges() - // Start watching the status file statusReader.onStatusUpdate = { [weak self] in self?.updateMonitoredDirectories() } statusReader.startWatching() + + // StatusReader reads the file eagerly in its init but only invokes + // onStatusUpdate on subsequent file-change events. Without this call, + // directoryURLs stays empty until the daemon next rewrites the file, + // so Finder never asks us for badges. + updateMonitoredDirectories() } // MARK: - Monitored Directories private func updateMonitoredDirectories() { - guard let status = statusReader.currentStatus else { return } + guard let status = statusReader.currentStatus else { + NSLog("GitSameBadge: updateMonitoredDirectories: no status yet") + return + } var urls = Set() - - // Add workspace roots for workspace in status.workspaces { urls.insert(URL(fileURLWithPath: workspace.root)) } - - // Add custom folders for folder in status.customFolders ?? [] { urls.insert(URL(fileURLWithPath: folder)) } FIFinderSyncController.default().directoryURLs = urls + let joined = urls.map { $0.path }.joined(separator: ",") + os_log("setDirectoryURLs count=%d paths=%{public}@", + log: gsbLog, type: .default, urls.count, joined) + NSLog("GitSameBadge: setDirectoryURLs count=%d paths=%@", urls.count, joined) } // MARK: - Badge Identifiers override func requestBadgeIdentifier(for url: URL) { let path = url.path + os_log("requestBadgeIdentifier path=%{public}@", log: gsbLog, type: .default, path) + NSLog("GitSameBadge: requestBadgeIdentifier for %@", path) - // Check if it's an org folder if statusReader.isOrgFolder(path: path) { FIFinderSyncController.default().setBadgeIdentifier( GitSameBadgeConstants.BadgeID.org, for: url @@ -55,7 +68,6 @@ class FinderSync: FIFinderSync { return } - // Check if it's a git repo if let repoStatus = statusReader.repoStatus(forPath: path) { let badgeID: String switch repoStatus.badge { diff --git a/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements b/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements index daa497e..807b075 100644 --- a/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements +++ b/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements @@ -2,13 +2,13 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - com.apple.security.temporary-exception.files.home-relative-path.read-only - - /.config/git-same/finder/ - + com.apple.security.app-sandbox + + com.apple.security.temporary-exception.files.absolute-path.read-only + + /Users/m/.config/git-same/finder/ + /Users/m/Manuel-Sun/Engineering/Same-GitHub/ + /Users/m/Manuel-Sun/Engineering/Same-SX/ + diff --git a/macos/Shared/Constants.swift b/macos/Shared/Constants.swift index 972ede2..92a399b 100644 --- a/macos/Shared/Constants.swift +++ b/macos/Shared/Constants.swift @@ -4,16 +4,23 @@ import Foundation enum GitSameBadgeConstants { + /// Real $HOME, bypassing the sandbox container redirect that + /// FileManager.default.homeDirectoryForCurrentUser applies. + static var realHomeDirectory: String { + if let pw = getpwuid(getuid()), let home = pw.pointee.pw_dir { + return String(cString: home) + } + return NSHomeDirectory() + } + /// Path to the status JSON file. static var statusFilePath: String { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return "\(home)/.config/git-same/finder/status.json" + return "\(realHomeDirectory)/.config/git-same/finder/status.json" } /// Path to the Unix socket for refresh requests. static var socketPath: String { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return "\(home)/.config/git-same/finder/finder.sock" + return "\(realHomeDirectory)/.config/git-same/finder/finder.sock" } /// Path to the git-same binary. From 7403ae79183b42ca243473767a25e3887c57fa00 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 00:53:33 +0200 Subject: [PATCH 08/89] Switch macOS Xcode project to in-repo source of truth, drop XcodeGen spec Remove macos/project.yml and commit the generated .xcodeproj directly so the repo no longer depends on XcodeGen. Per-user Xcode state (xcuserdata) stays gitignored. --- .gitignore | 6 +- macos/GitSameBadge.xcodeproj/project.pbxproj | 497 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + macos/project.yml | 76 --- 4 files changed, 507 insertions(+), 79 deletions(-) create mode 100644 macos/GitSameBadge.xcodeproj/project.pbxproj create mode 100644 macos/GitSameBadge.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 macos/project.yml diff --git a/.gitignore b/.gitignore index 17d5487..6dced14 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,8 @@ cobertura.xml # Claude Code .claude/settings.local.json -# Xcode (macOS FinderSync): project is generated by xcodegen from macos/project.yml -macos/*.xcodeproj/ -macos/*.xcworkspace/ +# Xcode (macOS FinderSync) +macos/*.xcodeproj/xcuserdata/ +macos/*.xcodeproj/project.xcworkspace/xcuserdata/ macos/build/ macos/DerivedData/ diff --git a/macos/GitSameBadge.xcodeproj/project.pbxproj b/macos/GitSameBadge.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9c7c9d7 --- /dev/null +++ b/macos/GitSameBadge.xcodeproj/project.pbxproj @@ -0,0 +1,497 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0C33F590E865457705EBD3FC /* BadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */; }; + 257B6856845A3AF4D14530C1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66AB39502A80452129E1480B /* ContentView.swift */; }; + 3C0E8784F4D4172A82A47E2A /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545B0726C8517036CC45E5F5 /* Constants.swift */; }; + 3EBDCECE5F467D2A9EFB1556 /* GitSameBadgeSync.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AE88C9527D7D39CD8F7A3C63 /* GitSameBadgeSync.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 5367D36A8F6767ED63560577 /* SocketProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81934E8C1D14054D093A454 /* SocketProtocol.swift */; }; + 823E8A57D3D847A5045E28C5 /* StatusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8F66E45A3050726370F5B8 /* StatusModels.swift */; }; + B3B75C712A824804DCF981B0 /* ContextMenuBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */; }; + B6105AA744F70B4B575E3571 /* SocketProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81934E8C1D14054D093A454 /* SocketProtocol.swift */; }; + C30B630B3AF0EFA7329FADA3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B6E312068B04A2AB272376 /* AppState.swift */; }; + DAB8F1E9D9269A7732C09F70 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545B0726C8517036CC45E5F5 /* Constants.swift */; }; + DC172C62A207996085942C13 /* FinderSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15307DCF3AB10B0512CD020A /* FinderSync.swift */; }; + E6366A4D11CE276E569491E4 /* StatusReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F95DF884CB07855C1D7C66 /* StatusReader.swift */; }; + EB3305CF18666124EB44A7EE /* StatusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8F66E45A3050726370F5B8 /* StatusModels.swift */; }; + F89A4BB411E9B253046349E2 /* GitSameBadgeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DE9F6071FC977891FF3134 /* GitSameBadgeApp.swift */; }; + FFC45929858BE49E81222E0E /* StatusReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F95DF884CB07855C1D7C66 /* StatusReader.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 90C0C4F8E56DE3CE22CC7031 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 76273B1C39F5CA108DFFF958 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0DD1148AB87797C1EFE47734; + remoteInfo = GitSameBadgeSync; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 774AE2229A24E660BCEBADE8 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 3EBDCECE5F467D2A9EFB1556 /* GitSameBadgeSync.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 15307DCF3AB10B0512CD020A /* FinderSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinderSync.swift; sourceTree = ""; }; + 545B0726C8517036CC45E5F5 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 61AF581BDDD5DCB1387D64E4 /* GitSameBadgeSync.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameBadgeSync.entitlements; sourceTree = ""; }; + 66AB39502A80452129E1480B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 83B6E312068B04A2AB272376 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + 8D40CF5193F71326936E5020 /* GitSameBadge.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameBadge.entitlements; sourceTree = ""; }; + 8EDDD96648AEBE55B27703CD /* GitSameBadge.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GitSameBadge.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuBuilder.swift; sourceTree = ""; }; + 9D8F66E45A3050726370F5B8 /* StatusModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusModels.swift; sourceTree = ""; }; + AB229AA7479834C325EB99A8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + AE88C9527D7D39CD8F7A3C63 /* GitSameBadgeSync.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GitSameBadgeSync.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeManager.swift; sourceTree = ""; }; + C6F95DF884CB07855C1D7C66 /* StatusReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusReader.swift; sourceTree = ""; }; + E4F4D50A6C496D2398C988EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + E6DE9F6071FC977891FF3134 /* GitSameBadgeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitSameBadgeApp.swift; sourceTree = ""; }; + E81934E8C1D14054D093A454 /* SocketProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketProtocol.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 10538FA5EC703BB6456B0A69 /* GitSameBadge */ = { + isa = PBXGroup; + children = ( + 8D40CF5193F71326936E5020 /* GitSameBadge.entitlements */, + E4F4D50A6C496D2398C988EA /* Info.plist */, + CB0CFDD7D56E2F16E9FC7F4D /* App */, + D692F12A3FB2A18EBF23C852 /* Models */, + DAA91174E582BD5E9000217A /* Views */, + ); + path = GitSameBadge; + sourceTree = ""; + }; + 43651E8F046591C71351AE27 = { + isa = PBXGroup; + children = ( + 10538FA5EC703BB6456B0A69 /* GitSameBadge */, + 75232AD31082397A565782DE /* GitSameBadgeSync */, + 6CFF0D192987E49E1BA2859F /* Shared */, + 44E4ECBCE05E2A7A5F2EE2CD /* Products */, + ); + sourceTree = ""; + }; + 44E4ECBCE05E2A7A5F2EE2CD /* Products */ = { + isa = PBXGroup; + children = ( + 8EDDD96648AEBE55B27703CD /* GitSameBadge.app */, + AE88C9527D7D39CD8F7A3C63 /* GitSameBadgeSync.appex */, + ); + name = Products; + sourceTree = ""; + }; + 6CFF0D192987E49E1BA2859F /* Shared */ = { + isa = PBXGroup; + children = ( + 545B0726C8517036CC45E5F5 /* Constants.swift */, + E81934E8C1D14054D093A454 /* SocketProtocol.swift */, + 9D8F66E45A3050726370F5B8 /* StatusModels.swift */, + C6F95DF884CB07855C1D7C66 /* StatusReader.swift */, + ); + path = Shared; + sourceTree = ""; + }; + 75232AD31082397A565782DE /* GitSameBadgeSync */ = { + isa = PBXGroup; + children = ( + B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */, + 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */, + 15307DCF3AB10B0512CD020A /* FinderSync.swift */, + 61AF581BDDD5DCB1387D64E4 /* GitSameBadgeSync.entitlements */, + AB229AA7479834C325EB99A8 /* Info.plist */, + ); + path = GitSameBadgeSync; + sourceTree = ""; + }; + CB0CFDD7D56E2F16E9FC7F4D /* App */ = { + isa = PBXGroup; + children = ( + E6DE9F6071FC977891FF3134 /* GitSameBadgeApp.swift */, + ); + path = App; + sourceTree = ""; + }; + D692F12A3FB2A18EBF23C852 /* Models */ = { + isa = PBXGroup; + children = ( + 83B6E312068B04A2AB272376 /* AppState.swift */, + ); + path = Models; + sourceTree = ""; + }; + DAA91174E582BD5E9000217A /* Views */ = { + isa = PBXGroup; + children = ( + 66AB39502A80452129E1480B /* ContentView.swift */, + ); + path = Views; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0DD1148AB87797C1EFE47734 /* GitSameBadgeSync */ = { + isa = PBXNativeTarget; + buildConfigurationList = 442FF77D5CAC09A6A71739F3 /* Build configuration list for PBXNativeTarget "GitSameBadgeSync" */; + buildPhases = ( + D43CBF7D710B9FD3DCC5713E /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GitSameBadgeSync; + packageProductDependencies = ( + ); + productName = GitSameBadgeSync; + productReference = AE88C9527D7D39CD8F7A3C63 /* GitSameBadgeSync.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 9299C38CC4E9759F948732EE /* GitSameBadge */ = { + isa = PBXNativeTarget; + buildConfigurationList = ED1FA95BAF692E77F6FE377D /* Build configuration list for PBXNativeTarget "GitSameBadge" */; + buildPhases = ( + 275130C7732709B4EC961D97 /* Sources */, + 774AE2229A24E660BCEBADE8 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 8DB2E8AC2A85E61C740DF3B4 /* PBXTargetDependency */, + ); + name = GitSameBadge; + packageProductDependencies = ( + ); + productName = GitSameBadge; + productReference = 8EDDD96648AEBE55B27703CD /* GitSameBadge.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 76273B1C39F5CA108DFFF958 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 0DD1148AB87797C1EFE47734 = { + ProvisioningStyle = Automatic; + }; + 9299C38CC4E9759F948732EE = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = C800113306606019226F625B /* Build configuration list for PBXProject "GitSameBadge" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 43651E8F046591C71351AE27; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 44E4ECBCE05E2A7A5F2EE2CD /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 9299C38CC4E9759F948732EE /* GitSameBadge */, + 0DD1148AB87797C1EFE47734 /* GitSameBadgeSync */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 275130C7732709B4EC961D97 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C30B630B3AF0EFA7329FADA3 /* AppState.swift in Sources */, + 3C0E8784F4D4172A82A47E2A /* Constants.swift in Sources */, + 257B6856845A3AF4D14530C1 /* ContentView.swift in Sources */, + F89A4BB411E9B253046349E2 /* GitSameBadgeApp.swift in Sources */, + B6105AA744F70B4B575E3571 /* SocketProtocol.swift in Sources */, + 823E8A57D3D847A5045E28C5 /* StatusModels.swift in Sources */, + E6366A4D11CE276E569491E4 /* StatusReader.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D43CBF7D710B9FD3DCC5713E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C33F590E865457705EBD3FC /* BadgeManager.swift in Sources */, + DAB8F1E9D9269A7732C09F70 /* Constants.swift in Sources */, + B3B75C712A824804DCF981B0 /* ContextMenuBuilder.swift in Sources */, + DC172C62A207996085942C13 /* FinderSync.swift in Sources */, + 5367D36A8F6767ED63560577 /* SocketProtocol.swift in Sources */, + EB3305CF18666124EB44A7EE /* StatusModels.swift in Sources */, + FFC45929858BE49E81222E0E /* StatusReader.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 8DB2E8AC2A85E61C740DF3B4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0DD1148AB87797C1EFE47734 /* GitSameBadgeSync */; + targetProxy = 90C0C4F8E56DE3CE22CC7031 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 4D4BC397944FC7FCA785061B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + 4D7367DC577A43874571BD32 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = GitSameBadgeSync/GitSameBadgeSync.entitlements; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 57KL6Y7V32; + INFOPLIST_FILE = GitSameBadgeSync/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.GitSameBadge.FinderSync"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + 512EA8428CC0E29C2F91C2C9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = GitSameBadgeSync/GitSameBadgeSync.entitlements; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 57KL6Y7V32; + INFOPLIST_FILE = GitSameBadgeSync/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.GitSameBadge.FinderSync"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + B87497438EDBE2BB4586AB78 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = GitSameBadge/GitSameBadge.entitlements; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 57KL6Y7V32; + INFOPLIST_FILE = GitSameBadge/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../PlugIns", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.GitSameBadge"; + SDKROOT = macosx; + SWIFT_OBJC_BRIDGING_HEADER = ""; + }; + name = Debug; + }; + C103BD2520D00F5F82A14303 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + D38F6C9211903948B76E7158 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = GitSameBadge/GitSameBadge.entitlements; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 57KL6Y7V32; + INFOPLIST_FILE = GitSameBadge/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../PlugIns", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.GitSameBadge"; + SDKROOT = macosx; + SWIFT_OBJC_BRIDGING_HEADER = ""; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 442FF77D5CAC09A6A71739F3 /* Build configuration list for PBXNativeTarget "GitSameBadgeSync" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4D7367DC577A43874571BD32 /* Debug */, + 512EA8428CC0E29C2F91C2C9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C800113306606019226F625B /* Build configuration list for PBXProject "GitSameBadge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4D4BC397944FC7FCA785061B /* Debug */, + C103BD2520D00F5F82A14303 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + ED1FA95BAF692E77F6FE377D /* Build configuration list for PBXNativeTarget "GitSameBadge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B87497438EDBE2BB4586AB78 /* Debug */, + D38F6C9211903948B76E7158 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 76273B1C39F5CA108DFFF958 /* Project object */; +} diff --git a/macos/GitSameBadge.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/macos/GitSameBadge.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/macos/GitSameBadge.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/project.yml b/macos/project.yml deleted file mode 100644 index 7697b90..0000000 --- a/macos/project.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: GitSameBadge - -options: - bundleIdPrefix: com.zaai.git-same - createIntermediateGroups: true - deploymentTarget: - macOS: "13.0" - xcodeVersion: "15.0" - -settings: - base: - SWIFT_VERSION: "5.9" - MACOSX_DEPLOYMENT_TARGET: "13.0" - CODE_SIGN_STYLE: Automatic - # DEVELOPMENT_TEAM is intentionally blank: set it in Xcode - # Signing & Capabilities, or via `xcodebuild DEVELOPMENT_TEAM=XXXXXXXXXX`. - DEVELOPMENT_TEAM: "" - ENABLE_HARDENED_RUNTIME: YES - PRODUCT_NAME: $(TARGET_NAME) - -targets: - GitSameBadge: - type: application - platform: macOS - sources: - - path: GitSameBadge - - path: Shared - info: - path: GitSameBadge/Info.plist - properties: - CFBundleDisplayName: GitSameBadge - CFBundleIdentifier: com.zaai.git-same.GitSameBadge - CFBundleName: GitSameBadge - CFBundleVersion: "1" - CFBundleShortVersionString: "1.0" - CFBundlePackageType: APPL - LSMinimumSystemVersion: "13.0" - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.zaai.git-same.GitSameBadge - CODE_SIGN_ENTITLEMENTS: GitSameBadge/GitSameBadge.entitlements - LD_RUNPATH_SEARCH_PATHS: - - "$(inherited)" - - "@executable_path/../Frameworks" - - "@executable_path/../PlugIns" - SWIFT_OBJC_BRIDGING_HEADER: "" - dependencies: - - target: GitSameBadgeSync - - GitSameBadgeSync: - type: app-extension - platform: macOS - sources: - - path: GitSameBadgeSync - - path: Shared - info: - path: GitSameBadgeSync/Info.plist - properties: - CFBundleDisplayName: GitSameBadge Sync - CFBundleIdentifier: com.zaai.git-same.GitSameBadge.FinderSync - CFBundleName: GitSameBadgeSync - CFBundleVersion: "1" - CFBundleShortVersionString: "1.0" - CFBundlePackageType: XPC! - NSExtension: - NSExtensionPointIdentifier: com.apple.FinderSync - NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).FinderSync - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.zaai.git-same.GitSameBadge.FinderSync - CODE_SIGN_ENTITLEMENTS: GitSameBadgeSync/GitSameBadgeSync.entitlements - LD_RUNPATH_SEARCH_PATHS: - - "$(inherited)" - - "@executable_path/../Frameworks" - - "@executable_path/../../../../Frameworks" - SKIP_INSTALL: YES From e51dcd6fac5d7a21c79caec0220c14d36560ba26 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 01:21:20 +0200 Subject: [PATCH 09/89] Trim FinderSync debug logging to lifecycle events Drop the per-request os_log and the duplicate NSLog lines added while diagnosing the macOS 26 badge-rendering path. Keep os_log calls on FinderSync init, updateMonitoredDirectories (including the no-status case), and setDirectoryURLs so future issues can still be investigated via `log show --predicate 'subsystem == "com.zaai.git-same.GitSameBadge.FinderSync"'`. --- macos/GitSameBadgeSync/FinderSync.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index fc9b441..a2e0272 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -15,7 +15,6 @@ class FinderSync: FIFinderSync { override init() { super.init() os_log("FinderSync init entered", log: gsbLog, type: .default) - NSLog("GitSameBadge: FinderSync init") BadgeManager.registerBadges() @@ -35,7 +34,7 @@ class FinderSync: FIFinderSync { private func updateMonitoredDirectories() { guard let status = statusReader.currentStatus else { - NSLog("GitSameBadge: updateMonitoredDirectories: no status yet") + os_log("updateMonitoredDirectories: no status yet", log: gsbLog, type: .default) return } @@ -51,15 +50,12 @@ class FinderSync: FIFinderSync { let joined = urls.map { $0.path }.joined(separator: ",") os_log("setDirectoryURLs count=%d paths=%{public}@", log: gsbLog, type: .default, urls.count, joined) - NSLog("GitSameBadge: setDirectoryURLs count=%d paths=%@", urls.count, joined) } // MARK: - Badge Identifiers override func requestBadgeIdentifier(for url: URL) { let path = url.path - os_log("requestBadgeIdentifier path=%{public}@", log: gsbLog, type: .default, path) - NSLog("GitSameBadge: requestBadgeIdentifier for %@", path) if statusReader.isOrgFolder(path: path) { FIFinderSyncController.default().setBadgeIdentifier( From 3ab89f8df3562b5a5fa1994e69d476b3d55ba192 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 01:46:37 +0200 Subject: [PATCH 10/89] Group Finder context menu under one Git-Same submenu to reduce clutter The FinderSync extension previously injected ~10 rows directly into Finder's right-click menu (badge header, branch, commit count, staged line, branches submenu, remotes submenu, worktrees submenu, refresh, terminal). Collapse them under a single "Git-Same" parent that expands on hover. Surface fields the daemon already writes but the extension ignored: workspace name, org name, default branch, top-level ahead/behind, all-branches-synced and all-worktrees-synced indicators, and an "Important ignored files" warning row with the matched patterns as a sub-submenu. Add an analogous Git-Same menu for org folders (org/workspace/path + Refresh) which previously got no menu at all. To support that without iterating the org-folder array on every right-click, replace the orgPaths Set in StatusReader with an orgFoldersByPath map and add an orgFolder(forPath:) accessor mirroring repoStatus(forPath:). --- .../GitSameBadgeSync/ContextMenuBuilder.swift | 236 ++++++++++++------ macos/GitSameBadgeSync/FinderSync.swift | 4 + macos/Shared/StatusReader.swift | 17 +- 3 files changed, 175 insertions(+), 82 deletions(-) diff --git a/macos/GitSameBadgeSync/ContextMenuBuilder.swift b/macos/GitSameBadgeSync/ContextMenuBuilder.swift index 9fd8b97..6e7d398 100644 --- a/macos/GitSameBadgeSync/ContextMenuBuilder.swift +++ b/macos/GitSameBadgeSync/ContextMenuBuilder.swift @@ -1,117 +1,201 @@ // ContextMenuBuilder.swift -// Builds the right-click context menu for git repository folders. +// Builds the right-click context menu for git repository folders and org folders. +// Everything lives under a single top-level "Git-Same" item so the Finder +// context menu stays clean. import Cocoa enum ContextMenuBuilder { /// Build the context menu for a repository. + /// Returns an NSMenu with exactly one item: a `Git-Same` row whose + /// submenu contains all the data and actions. static func build(for repo: FinderRepoStatus, socketClient: SocketClient) -> NSMenu { let menu = NSMenu(title: "GitSameBadge") + menu.addItem(parentItem(badge: repo.badge, submenu: repoSubmenu(for: repo))) + return menu + } + + /// Build the context menu for an organization folder. + static func build(for org: OrgFolderInfo) -> NSMenu { + let menu = NSMenu(title: "GitSameBadge") + menu.addItem(parentItem(badge: nil, submenu: orgSubmenu(for: org))) + return menu + } + + // MARK: - Parent item - // Header with badge indicator - let badgeEmoji: String - switch repo.badge { - case .green: badgeEmoji = "\u{1F7E2}" // green circle - case .blue: badgeEmoji = "\u{1F535}" // blue circle - case .orange: badgeEmoji = "\u{1F7E0}" // orange circle - case .red: badgeEmoji = "\u{1F534}" // red circle + private static func parentItem(badge: Badge?, submenu: NSMenu) -> NSMenuItem { + let prefix: String + if let badge = badge { + switch badge { + case .green: prefix = "\u{1F7E2} " // green circle + case .blue: prefix = "\u{1F535} " // blue circle + case .orange: prefix = "\u{1F7E0} " // orange circle + case .red: prefix = "\u{1F534} " // red circle + } + } else { + prefix = "\u{1F7E3} " // purple circle for org folders } - let header = NSMenuItem(title: "\(badgeEmoji) GitSameBadge", action: nil, keyEquivalent: "") - header.isEnabled = false - menu.addItem(header) - menu.addItem(NSMenuItem.separator()) + let item = NSMenuItem(title: "\(prefix)Git-Same", action: nil, keyEquivalent: "") + item.submenu = submenu + return item + } + + // MARK: - Repo submenu - // Branch info - menu.addItem(infoItem("Branch: \(repo.currentBranch)")) - menu.addItem(infoItem("Commits: \(repo.commitCount)")) + private static func repoSubmenu(for repo: FinderRepoStatus) -> NSMenu { + let submenu = NSMenu() - // Staged / Unstaged - let changesLine = "Staged: \(repo.stagedCount) | Unstaged: \(repo.unstagedCount)" - menu.addItem(infoItem(changesLine)) + var headerAdded = false + if let workspace = repo.workspace { + submenu.addItem(infoItem("Workspace: \(workspace)")) + headerAdded = true + } + if let org = repo.org { + submenu.addItem(infoItem("Org: \(org)")) + headerAdded = true + } + if headerAdded { + submenu.addItem(NSMenuItem.separator()) + } + + submenu.addItem(infoItem("Branch: \(repo.currentBranch)")) + if let defaultBranch = repo.defaultBranch, defaultBranch != repo.currentBranch { + submenu.addItem(infoItem("Default: \(defaultBranch)")) + } + + if repo.ahead > 0 || repo.behind > 0 { + submenu.addItem(infoItem("Ahead: \(repo.ahead) | Behind: \(repo.behind)")) + } + + submenu.addItem(infoItem("Commits: \(repo.commitCount)")) + submenu.addItem(infoItem("Staged: \(repo.stagedCount) | Unstaged: \(repo.unstagedCount)")) if repo.untrackedCount > 0 { - menu.addItem(infoItem("Untracked: \(repo.untrackedCount)")) + submenu.addItem(infoItem("Untracked: \(repo.untrackedCount)")) } if repo.stashCount > 0 { - menu.addItem(infoItem("Stashes: \(repo.stashCount)")) + submenu.addItem(infoItem("Stashes: \(repo.stashCount)")) } - // Branches submenu - if !repo.branches.isEmpty { - menu.addItem(NSMenuItem.separator()) - let branchesItem = NSMenuItem(title: "Branches", action: nil, keyEquivalent: "") - let branchesSubmenu = NSMenu() - - for branch in repo.branches { - let checkmark = (branch.name == repo.currentBranch) ? "\u{2713} " : " " - let syncStatus: String - if branch.synced { - syncStatus = "(synced)" - } else if branch.ahead > 0 && branch.behind > 0 { - syncStatus = "(ahead \(branch.ahead), behind \(branch.behind))" - } else if branch.ahead > 0 { - syncStatus = "(ahead \(branch.ahead))" - } else if branch.behind > 0 { - syncStatus = "(behind \(branch.behind))" - } else if branch.upstream == nil { - syncStatus = "(no upstream)" - } else { - syncStatus = "" + if repo.hasImportantIgnoredFiles { + let patterns = repo.importantIgnoredFiles ?? [] + let warnTitle = "\u{26A0} Important ignored files (\(patterns.count))" + let warnItem = NSMenuItem(title: warnTitle, action: nil, keyEquivalent: "") + if !patterns.isEmpty { + let warnSubmenu = NSMenu() + for pattern in patterns { + warnSubmenu.addItem(infoItem(pattern)) } - - let title = "\(checkmark)\(branch.name) \(syncStatus)" - branchesSubmenu.addItem(infoItem(title)) + warnItem.submenu = warnSubmenu + } else { + warnItem.isEnabled = false } + submenu.addItem(warnItem) + } - branchesItem.submenu = branchesSubmenu - menu.addItem(branchesItem) + if !repo.branches.isEmpty { + submenu.addItem(NSMenuItem.separator()) + let label = repo.allBranchesSynced + ? "Branches (\u{2713} all synced)" + : "Branches (some out of sync)" + submenu.addItem(branchesItem(title: label, branches: repo.branches, + currentBranch: repo.currentBranch)) } - // Remotes submenu if !repo.remotes.isEmpty { - let remotesItem = NSMenuItem(title: "Remotes", action: nil, keyEquivalent: "") - let remotesSubmenu = NSMenu() - for remote in repo.remotes { - remotesSubmenu.addItem(infoItem("\(remote.name): \(remote.url)")) - } - remotesItem.submenu = remotesSubmenu - menu.addItem(remotesItem) + submenu.addItem(remotesItem(remotes: repo.remotes)) } - // Worktrees submenu if !repo.worktrees.isEmpty { - let worktreesItem = NSMenuItem(title: "Worktrees", action: nil, keyEquivalent: "") - let worktreesSubmenu = NSMenu() - for wt in repo.worktrees { - let syncMark = wt.synced ? "\u{2713}" : "\u{2717}" - let branch = wt.branch ?? "detached" - worktreesSubmenu.addItem(infoItem("\(wt.path) (\(branch)) \(syncMark)")) - } - worktreesItem.submenu = worktreesSubmenu - menu.addItem(worktreesItem) + let label = repo.allWorktreesSynced + ? "Worktrees (\u{2713} all synced)" + : "Worktrees (some out of sync)" + submenu.addItem(worktreesItem(title: label, worktrees: repo.worktrees)) } - // Actions - menu.addItem(NSMenuItem.separator()) - - let refreshItem = NSMenuItem( + submenu.addItem(NSMenuItem.separator()) + submenu.addItem(NSMenuItem( title: "\u{21BB} Refresh Status", action: #selector(FinderSync.refreshStatus(_:)), keyEquivalent: "" - ) - menu.addItem(refreshItem) - - let terminalItem = NSMenuItem( + )) + submenu.addItem(NSMenuItem( title: "Open in Terminal", action: #selector(FinderSync.openInTerminal(_:)), keyEquivalent: "" - ) - menu.addItem(terminalItem) + )) - return menu + return submenu + } + + // MARK: - Org submenu + + private static func orgSubmenu(for org: OrgFolderInfo) -> NSMenu { + let submenu = NSMenu() + submenu.addItem(infoItem("Org: \(org.org)")) + submenu.addItem(infoItem("Workspace: \(org.workspace)")) + submenu.addItem(infoItem("Path: \(org.path)")) + submenu.addItem(NSMenuItem.separator()) + submenu.addItem(NSMenuItem( + title: "\u{21BB} Refresh Status", + action: #selector(FinderSync.refreshStatus(_:)), + keyEquivalent: "" + )) + return submenu + } + + // MARK: - Sub-submenus + + private static func branchesItem(title: String, branches: [FinderBranchInfo], + currentBranch: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + let sub = NSMenu() + for branch in branches { + let checkmark = (branch.name == currentBranch) ? "\u{2713} " : " " + let syncStatus: String + if branch.synced { + syncStatus = "(synced)" + } else if branch.ahead > 0 && branch.behind > 0 { + syncStatus = "(ahead \(branch.ahead), behind \(branch.behind))" + } else if branch.ahead > 0 { + syncStatus = "(ahead \(branch.ahead))" + } else if branch.behind > 0 { + syncStatus = "(behind \(branch.behind))" + } else if branch.upstream == nil { + syncStatus = "(no upstream)" + } else { + syncStatus = "" + } + sub.addItem(infoItem("\(checkmark)\(branch.name) \(syncStatus)")) + } + item.submenu = sub + return item + } + + private static func remotesItem(remotes: [FinderRemoteInfo]) -> NSMenuItem { + let item = NSMenuItem(title: "Remotes", action: nil, keyEquivalent: "") + let sub = NSMenu() + for remote in remotes { + sub.addItem(infoItem("\(remote.name): \(remote.url)")) + } + item.submenu = sub + return item + } + + private static func worktreesItem(title: String, + worktrees: [FinderWorktreeInfo]) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + let sub = NSMenu() + for wt in worktrees { + let syncMark = wt.synced ? "\u{2713}" : "\u{2717}" + let branch = wt.branch ?? "detached" + sub.addItem(infoItem("\(wt.path) (\(branch)) \(syncMark)")) + } + item.submenu = sub + return item } - /// Create a disabled info item (non-clickable label). private static func infoItem(_ title: String) -> NSMenuItem { let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") item.isEnabled = false diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index a2e0272..09ea874 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -103,6 +103,10 @@ class FinderSync: FIFinderSync { return ContextMenuBuilder.build(for: repoStatus, socketClient: socketClient) } + if let orgFolder = statusReader.orgFolder(forPath: path) { + return ContextMenuBuilder.build(for: orgFolder) + } + return NSMenu() } diff --git a/macos/Shared/StatusReader.swift b/macos/Shared/StatusReader.swift index 390ee75..85fc40f 100644 --- a/macos/Shared/StatusReader.swift +++ b/macos/Shared/StatusReader.swift @@ -16,8 +16,8 @@ class StatusReader { /// Lookup cache for repo status by path. private var reposByPath: [String: FinderRepoStatus] = [:] - /// Lookup cache for org folders. - private var orgPaths: Set = [] + /// Lookup cache for org folders by path. + private var orgFoldersByPath: [String: OrgFolderInfo] = [:] private var fileMonitor: DispatchSourceFileSystemObject? private var fileDescriptor: Int32 = -1 @@ -85,14 +85,14 @@ class StatusReader { repoMap[repo.path] = repo } - var orgSet: Set = [] + var orgMap: [String: OrgFolderInfo] = [:] for org in status.orgFolders ?? [] { - orgSet.insert(org.path) + orgMap[org.path] = org } self.currentStatus = status self.reposByPath = repoMap - self.orgPaths = orgSet + self.orgFoldersByPath = orgMap } catch { // Ignore parse errors (file might be mid-write, though atomic rename should prevent this) } @@ -105,7 +105,12 @@ class StatusReader { /// Check if the given path is an org folder. func isOrgFolder(path: String) -> Bool { - return orgPaths.contains(path) + return orgFoldersByPath[path] != nil + } + + /// Get the org-folder info for the given path, if any. + func orgFolder(forPath path: String) -> OrgFolderInfo? { + return orgFoldersByPath[path] } deinit { From e78d693f2f131db37d88e61d37310a86d6243a80 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 01:52:43 +0200 Subject: [PATCH 11/89] Enrich Git-Same context menu with org aggregates and last-scan info The Git-Same submenu shipped in the previous commit was thin, especially for org folders (just org/workspace/path/refresh). Surface the rest of the data the daemon already sends so the right-click menu becomes a useful at-a-glance dashboard. Repo menu adds: human-readable status label ("Synced", "Uncommitted Changes", etc.), full path, last-scan timestamp (relative + absolute), counts on Branches/Remotes/Worktrees headers, and per-branch detail sub-submenus showing upstream/ahead/behind/synced. Org menu adds: repo count + per-badge breakdown, totals (commits, uncommitted, ahead/behind, stashes), a warning submenu listing repos with sensitive ignored files, a Repos submenu with one row per repo (badge + branch) each expanding to a per-repo detail mini-submenu, workspace root, total orgs in workspace + a sub-submenu listing them with a checkmark on the current one, and last-scan timestamp. Pass the extra data into ContextMenuBuilder by extending its API: build(for:timestamp:socketClient:) for repos, build(for:repos: workspaceInfo:timestamp:) for orgs. menu(for:) gathers the timestamp and (for orgs) filters status.repos by org + workspace. Avoids referencing OwnerType so the change stays decoupled from the parallel owner-type workstream. --- .../GitSameBadgeSync/ContextMenuBuilder.swift | 300 ++++++++++++++++-- macos/GitSameBadgeSync/FinderSync.swift | 19 +- 2 files changed, 283 insertions(+), 36 deletions(-) diff --git a/macos/GitSameBadgeSync/ContextMenuBuilder.swift b/macos/GitSameBadgeSync/ContextMenuBuilder.swift index 6e7d398..cd727a6 100644 --- a/macos/GitSameBadgeSync/ContextMenuBuilder.swift +++ b/macos/GitSameBadgeSync/ContextMenuBuilder.swift @@ -9,56 +9,69 @@ enum ContextMenuBuilder { /// Build the context menu for a repository. /// Returns an NSMenu with exactly one item: a `Git-Same` row whose /// submenu contains all the data and actions. - static func build(for repo: FinderRepoStatus, socketClient: SocketClient) -> NSMenu { + static func build(for repo: FinderRepoStatus, + timestamp: String?, + socketClient: SocketClient) -> NSMenu { let menu = NSMenu(title: "GitSameBadge") - menu.addItem(parentItem(badge: repo.badge, submenu: repoSubmenu(for: repo))) + menu.addItem(parentItem(badge: repo.badge, + submenu: repoSubmenu(for: repo, timestamp: timestamp))) return menu } - /// Build the context menu for an organization folder. - static func build(for org: OrgFolderInfo) -> NSMenu { + /// Build the context menu for an organization (or user) folder. + static func build(for org: OrgFolderInfo, + repos: [FinderRepoStatus], + workspaceInfo: FinderWorkspaceInfo?, + timestamp: String?) -> NSMenu { let menu = NSMenu(title: "GitSameBadge") - menu.addItem(parentItem(badge: nil, submenu: orgSubmenu(for: org))) + menu.addItem(parentItem(badge: nil, + submenu: orgSubmenu(for: org, repos: repos, + workspaceInfo: workspaceInfo, + timestamp: timestamp))) return menu } // MARK: - Parent item private static func parentItem(badge: Badge?, submenu: NSMenu) -> NSMenuItem { - let prefix: String - if let badge = badge { - switch badge { - case .green: prefix = "\u{1F7E2} " // green circle - case .blue: prefix = "\u{1F535} " // blue circle - case .orange: prefix = "\u{1F7E0} " // orange circle - case .red: prefix = "\u{1F534} " // red circle - } - } else { - prefix = "\u{1F7E3} " // purple circle for org folders - } - - let item = NSMenuItem(title: "\(prefix)Git-Same", action: nil, keyEquivalent: "") + let prefix = badge.map(badgeEmoji) ?? "\u{1F7E3}" + let item = NSMenuItem(title: "\(prefix) Git-Same", action: nil, keyEquivalent: "") item.submenu = submenu return item } + private static func badgeEmoji(_ badge: Badge) -> String { + switch badge { + case .green: return "\u{1F7E2}" // green circle + case .blue: return "\u{1F535}" // blue circle + case .orange: return "\u{1F7E0}" // orange circle + case .red: return "\u{1F534}" // red circle + } + } + + private static func badgeMeaning(_ badge: Badge) -> String { + switch badge { + case .green: return "Synced" + case .blue: return "Has Local Config" + case .orange: return "Partially Synced" + case .red: return "Uncommitted Changes" + } + } + // MARK: - Repo submenu - private static func repoSubmenu(for repo: FinderRepoStatus) -> NSMenu { + private static func repoSubmenu(for repo: FinderRepoStatus, timestamp: String?) -> NSMenu { let submenu = NSMenu() - var headerAdded = false + submenu.addItem(infoItem("Status: \(badgeMeaning(repo.badge))")) if let workspace = repo.workspace { submenu.addItem(infoItem("Workspace: \(workspace)")) - headerAdded = true } if let org = repo.org { submenu.addItem(infoItem("Org: \(org)")) - headerAdded = true - } - if headerAdded { - submenu.addItem(NSMenuItem.separator()) } + submenu.addItem(infoItem("Path: \(repo.path)")) + submenu.addItem(NSMenuItem.separator()) submenu.addItem(infoItem("Branch: \(repo.currentBranch)")) if let defaultBranch = repo.defaultBranch, defaultBranch != repo.currentBranch { @@ -70,7 +83,9 @@ enum ContextMenuBuilder { } submenu.addItem(infoItem("Commits: \(repo.commitCount)")) - submenu.addItem(infoItem("Staged: \(repo.stagedCount) | Unstaged: \(repo.unstagedCount)")) + submenu.addItem(infoItem( + "Staged: \(repo.stagedCount) | Unstaged: \(repo.unstagedCount)" + )) if repo.untrackedCount > 0 { submenu.addItem(infoItem("Untracked: \(repo.untrackedCount)")) } @@ -97,8 +112,8 @@ enum ContextMenuBuilder { if !repo.branches.isEmpty { submenu.addItem(NSMenuItem.separator()) let label = repo.allBranchesSynced - ? "Branches (\u{2713} all synced)" - : "Branches (some out of sync)" + ? "Branches \(repo.branches.count) (\u{2713} all synced)" + : "Branches \(repo.branches.count) (some out of sync)" submenu.addItem(branchesItem(title: label, branches: repo.branches, currentBranch: repo.currentBranch)) } @@ -109,11 +124,16 @@ enum ContextMenuBuilder { if !repo.worktrees.isEmpty { let label = repo.allWorktreesSynced - ? "Worktrees (\u{2713} all synced)" - : "Worktrees (some out of sync)" + ? "Worktrees \(repo.worktrees.count) (\u{2713} all synced)" + : "Worktrees \(repo.worktrees.count) (some out of sync)" submenu.addItem(worktreesItem(title: label, worktrees: repo.worktrees)) } + if let stamp = formatTimestamp(timestamp) { + submenu.addItem(NSMenuItem.separator()) + submenu.addItem(infoItem("Last scan: \(stamp)")) + } + submenu.addItem(NSMenuItem.separator()) submenu.addItem(NSMenuItem( title: "\u{21BB} Refresh Status", @@ -131,11 +151,83 @@ enum ContextMenuBuilder { // MARK: - Org submenu - private static func orgSubmenu(for org: OrgFolderInfo) -> NSMenu { + private static func orgSubmenu(for org: OrgFolderInfo, + repos: [FinderRepoStatus], + workspaceInfo: FinderWorkspaceInfo?, + timestamp: String?) -> NSMenu { let submenu = NSMenu() - submenu.addItem(infoItem("Org: \(org.org)")) + + submenu.addItem(infoItem("Owner: \(org.org)")) submenu.addItem(infoItem("Workspace: \(org.workspace)")) submenu.addItem(infoItem("Path: \(org.path)")) + + submenu.addItem(NSMenuItem.separator()) + + let counts = badgeCounts(for: repos) + submenu.addItem(infoItem("Repos: \(repos.count)")) + if !repos.isEmpty { + submenu.addItem(infoItem( + "\u{1F7E2} \(counts.green) | \u{1F535} \(counts.blue) | " + + "\u{1F7E0} \(counts.orange) | \u{1F534} \(counts.red)" + )) + } + + let totals = aggregate(repos: repos) + submenu.addItem(infoItem("Total commits: \(totals.commits)")) + if totals.staged > 0 || totals.unstaged > 0 || totals.untracked > 0 { + submenu.addItem(infoItem( + "Uncommitted \u{2014} staged: \(totals.staged), " + + "unstaged: \(totals.unstaged), untracked: \(totals.untracked)" + )) + } + if totals.ahead > 0 || totals.behind > 0 { + submenu.addItem(infoItem( + "Total ahead: \(totals.ahead) | behind: \(totals.behind)" + )) + } + if totals.stashes > 0 { + submenu.addItem(infoItem("Total stashes: \(totals.stashes)")) + } + + let secretRepos = repos.filter { $0.hasImportantIgnoredFiles } + if !secretRepos.isEmpty { + let warnTitle = "\u{26A0} Repos with sensitive files (\(secretRepos.count))" + let warnItem = NSMenuItem(title: warnTitle, action: nil, keyEquivalent: "") + let warnSubmenu = NSMenu() + for r in secretRepos.sorted(by: { repoBasename($0) < repoBasename($1) }) { + warnSubmenu.addItem(infoItem(repoBasename(r))) + } + warnItem.submenu = warnSubmenu + submenu.addItem(warnItem) + } + + if !repos.isEmpty { + submenu.addItem(NSMenuItem.separator()) + submenu.addItem(reposItem(repos: repos)) + } + + if let ws = workspaceInfo { + submenu.addItem(NSMenuItem.separator()) + submenu.addItem(infoItem("Workspace root: \(ws.root)")) + submenu.addItem(infoItem("Orgs in workspace: \(ws.orgs.count)")) + if !ws.orgs.isEmpty { + let orgsItem = NSMenuItem(title: "All orgs in workspace", + action: nil, keyEquivalent: "") + let orgsSubmenu = NSMenu() + for name in ws.orgs.sorted() { + let marker = (name == org.org) ? "\u{2713} " : " " + orgsSubmenu.addItem(infoItem("\(marker)\(name)")) + } + orgsItem.submenu = orgsSubmenu + submenu.addItem(orgsItem) + } + } + + if let stamp = formatTimestamp(timestamp) { + submenu.addItem(NSMenuItem.separator()) + submenu.addItem(infoItem("Last scan: \(stamp)")) + } + submenu.addItem(NSMenuItem.separator()) submenu.addItem(NSMenuItem( title: "\u{21BB} Refresh Status", @@ -147,6 +239,49 @@ enum ContextMenuBuilder { // MARK: - Sub-submenus + private static func reposItem(repos: [FinderRepoStatus]) -> NSMenuItem { + let item = NSMenuItem(title: "Repos (\(repos.count))", + action: nil, keyEquivalent: "") + let sub = NSMenu() + for r in repos.sorted(by: { repoBasename($0) < repoBasename($1) }) { + let line = "\(badgeEmoji(r.badge)) \(repoBasename(r)) [\(r.currentBranch)]" + let row = NSMenuItem(title: line, action: nil, keyEquivalent: "") + row.submenu = repoMiniSubmenu(for: r) + sub.addItem(row) + } + item.submenu = sub + return item + } + + private static func repoMiniSubmenu(for repo: FinderRepoStatus) -> NSMenu { + let sub = NSMenu() + sub.addItem(infoItem("Status: \(badgeMeaning(repo.badge))")) + sub.addItem(infoItem("Branch: \(repo.currentBranch)")) + if let def = repo.defaultBranch, def != repo.currentBranch { + sub.addItem(infoItem("Default: \(def)")) + } + sub.addItem(infoItem("Commits: \(repo.commitCount)")) + if repo.ahead > 0 || repo.behind > 0 { + sub.addItem(infoItem("Ahead: \(repo.ahead) | Behind: \(repo.behind)")) + } + if repo.stagedCount > 0 || repo.unstagedCount > 0 || repo.untrackedCount > 0 { + sub.addItem(infoItem( + "Staged: \(repo.stagedCount) | Unstaged: \(repo.unstagedCount)" + + " | Untracked: \(repo.untrackedCount)" + )) + } + if repo.stashCount > 0 { + sub.addItem(infoItem("Stashes: \(repo.stashCount)")) + } + if repo.hasImportantIgnoredFiles { + sub.addItem(infoItem( + "\u{26A0} Important ignored files: \((repo.importantIgnoredFiles ?? []).count)" + )) + } + sub.addItem(infoItem("Path: \(repo.path)")) + return sub + } + private static func branchesItem(title: String, branches: [FinderBranchInfo], currentBranch: String) -> NSMenuItem { let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") @@ -167,14 +302,26 @@ enum ContextMenuBuilder { } else { syncStatus = "" } - sub.addItem(infoItem("\(checkmark)\(branch.name) \(syncStatus)")) + let row = NSMenuItem(title: "\(checkmark)\(branch.name) \(syncStatus)", + action: nil, keyEquivalent: "") + row.isEnabled = false + if let upstream = branch.upstream { + let detail = NSMenu() + detail.addItem(infoItem("Upstream: \(upstream)")) + detail.addItem(infoItem("Ahead: \(branch.ahead) | Behind: \(branch.behind)")) + detail.addItem(infoItem(branch.synced ? "Synced" : "Out of sync")) + row.submenu = detail + row.isEnabled = true + } + sub.addItem(row) } item.submenu = sub return item } private static func remotesItem(remotes: [FinderRemoteInfo]) -> NSMenuItem { - let item = NSMenuItem(title: "Remotes", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: "Remotes (\(remotes.count))", + action: nil, keyEquivalent: "") let sub = NSMenu() for remote in remotes { sub.addItem(infoItem("\(remote.name): \(remote.url)")) @@ -196,6 +343,91 @@ enum ContextMenuBuilder { return item } + // MARK: - Helpers + + private struct BadgeCounts { + var green = 0 + var blue = 0 + var orange = 0 + var red = 0 + } + + private static func badgeCounts(for repos: [FinderRepoStatus]) -> BadgeCounts { + var counts = BadgeCounts() + for r in repos { + switch r.badge { + case .green: counts.green += 1 + case .blue: counts.blue += 1 + case .orange: counts.orange += 1 + case .red: counts.red += 1 + } + } + return counts + } + + private struct AggregateTotals { + var commits: UInt64 = 0 + var staged: Int = 0 + var unstaged: Int = 0 + var untracked: Int = 0 + var ahead: UInt32 = 0 + var behind: UInt32 = 0 + var stashes: Int = 0 + } + + private static func aggregate(repos: [FinderRepoStatus]) -> AggregateTotals { + var t = AggregateTotals() + for r in repos { + t.commits += r.commitCount + t.staged += r.stagedCount + t.unstaged += r.unstagedCount + t.untracked += r.untrackedCount + t.ahead += r.ahead + t.behind += r.behind + t.stashes += r.stashCount + } + return t + } + + private static func repoBasename(_ repo: FinderRepoStatus) -> String { + return (repo.path as NSString).lastPathComponent + } + + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let isoFormatterNoFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() + + private static let absoluteFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + f.timeStyle = .medium + return f + }() + + private static func formatTimestamp(_ stamp: String?) -> String? { + guard let stamp = stamp, !stamp.isEmpty else { return nil } + let date = isoFormatter.date(from: stamp) + ?? isoFormatterNoFractional.date(from: stamp) + guard let date = date else { return stamp } + let relative = relativeFormatter.localizedString(for: date, relativeTo: Date()) + let absolute = absoluteFormatter.string(from: date) + return "\(relative) (\(absolute))" + } + private static func infoItem(_ title: String) -> NSMenuItem { let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") item.isEnabled = false diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index 09ea874..5340ef6 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -98,13 +98,28 @@ class FinderSync: FIFinderSync { } let path = targetURL.path + let status = statusReader.currentStatus + let timestamp = status?.timestamp if let repoStatus = statusReader.repoStatus(forPath: path) { - return ContextMenuBuilder.build(for: repoStatus, socketClient: socketClient) + return ContextMenuBuilder.build( + for: repoStatus, + timestamp: timestamp, + socketClient: socketClient + ) } if let orgFolder = statusReader.orgFolder(forPath: path) { - return ContextMenuBuilder.build(for: orgFolder) + let orgRepos = (status?.repos ?? []).filter { + $0.org == orgFolder.org && $0.workspace == orgFolder.workspace + } + let workspaceInfo = status?.workspaces.first { $0.name == orgFolder.workspace } + return ContextMenuBuilder.build( + for: orgFolder, + repos: orgRepos, + workspaceInfo: workspaceInfo, + timestamp: timestamp + ) } return NSMenu() From f4b7c2b49b983d8d86c16c0ee814cb344d90fbd9 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 02:03:51 +0200 Subject: [PATCH 12/89] Group Git-Same menu data into Organization, Repositories, and details The Git-Same submenu had grown to ~15 inline rows (org info, aggregate stats, repo list, workspace info, last scan, refresh) which made it hard to scan. Group the data into three labeled sub-submenus so users can drill into the level they care about: - Organization: workspace, org/owner, paths, all sibling orgs - Repositories: aggregate stats and per-badge counts (org menu) or this repo's own status (repo menu) - Repository details: branches, remotes, worktrees (repo menu) or the per-repo list (org menu) The Last scan timestamp and the Refresh / Open in Terminal actions stay at the top level of the Git-Same submenu so they remain one hover away. Extends ContextMenuBuilder.build(for repo:) to also accept a FinderWorkspaceInfo so the repo's Organization submenu can show workspace root and sibling orgs the same way the org-folder menu does. --- .../GitSameBadgeSync/ContextMenuBuilder.swift | 318 +++++++++++------- macos/GitSameBadgeSync/FinderSync.swift | 4 + 2 files changed, 192 insertions(+), 130 deletions(-) diff --git a/macos/GitSameBadgeSync/ContextMenuBuilder.swift b/macos/GitSameBadgeSync/ContextMenuBuilder.swift index cd727a6..2f6444d 100644 --- a/macos/GitSameBadgeSync/ContextMenuBuilder.swift +++ b/macos/GitSameBadgeSync/ContextMenuBuilder.swift @@ -1,20 +1,22 @@ // ContextMenuBuilder.swift // Builds the right-click context menu for git repository folders and org folders. -// Everything lives under a single top-level "Git-Same" item so the Finder -// context menu stays clean. +// Everything lives under a single top-level "Git-Same" item. Inside, data is +// grouped into three sub-submenus: Organization, Repositories, Repository +// details — followed by the last-scan timestamp and the action items. import Cocoa enum ContextMenuBuilder { - /// Build the context menu for a repository. - /// Returns an NSMenu with exactly one item: a `Git-Same` row whose - /// submenu contains all the data and actions. + /// Build the context menu for a repository folder. static func build(for repo: FinderRepoStatus, + workspaceInfo: FinderWorkspaceInfo?, timestamp: String?, socketClient: SocketClient) -> NSMenu { let menu = NSMenu(title: "GitSameBadge") menu.addItem(parentItem(badge: repo.badge, - submenu: repoSubmenu(for: repo, timestamp: timestamp))) + submenu: repoRoot(repo: repo, + workspaceInfo: workspaceInfo, + timestamp: timestamp))) return menu } @@ -25,9 +27,9 @@ enum ContextMenuBuilder { timestamp: String?) -> NSMenu { let menu = NSMenu(title: "GitSameBadge") menu.addItem(parentItem(badge: nil, - submenu: orgSubmenu(for: org, repos: repos, - workspaceInfo: workspaceInfo, - timestamp: timestamp))) + submenu: orgRoot(org: org, repos: repos, + workspaceInfo: workspaceInfo, + timestamp: timestamp))) return menu } @@ -40,59 +42,91 @@ enum ContextMenuBuilder { return item } - private static func badgeEmoji(_ badge: Badge) -> String { - switch badge { - case .green: return "\u{1F7E2}" // green circle - case .blue: return "\u{1F535}" // blue circle - case .orange: return "\u{1F7E0}" // orange circle - case .red: return "\u{1F534}" // red circle - } - } + // MARK: - Repo root submenu - private static func badgeMeaning(_ badge: Badge) -> String { - switch badge { - case .green: return "Synced" - case .blue: return "Has Local Config" - case .orange: return "Partially Synced" - case .red: return "Uncommitted Changes" - } - } + private static func repoRoot(repo: FinderRepoStatus, + workspaceInfo: FinderWorkspaceInfo?, + timestamp: String?) -> NSMenu { + let root = NSMenu() - // MARK: - Repo submenu + root.addItem(submenuRow(title: "Organization", + content: repoOrgSubmenu(repo: repo, + workspaceInfo: workspaceInfo))) + root.addItem(submenuRow(title: "Repository", + content: repoSelfSubmenu(repo: repo))) + root.addItem(submenuRow(title: "Repository details", + content: repoDetailsSubmenu(repo: repo))) + + if let stamp = formatTimestamp(timestamp) { + root.addItem(NSMenuItem.separator()) + root.addItem(infoItem("Last scan: \(stamp)")) + } - private static func repoSubmenu(for repo: FinderRepoStatus, timestamp: String?) -> NSMenu { - let submenu = NSMenu() + root.addItem(NSMenuItem.separator()) + root.addItem(NSMenuItem( + title: "\u{21BB} Refresh Status", + action: #selector(FinderSync.refreshStatus(_:)), + keyEquivalent: "" + )) + root.addItem(NSMenuItem( + title: "Open in Terminal", + action: #selector(FinderSync.openInTerminal(_:)), + keyEquivalent: "" + )) + return root + } - submenu.addItem(infoItem("Status: \(badgeMeaning(repo.badge))")) + private static func repoOrgSubmenu(repo: FinderRepoStatus, + workspaceInfo: FinderWorkspaceInfo?) -> NSMenu { + let sub = NSMenu() if let workspace = repo.workspace { - submenu.addItem(infoItem("Workspace: \(workspace)")) + sub.addItem(infoItem("Workspace: \(workspace)")) } if let org = repo.org { - submenu.addItem(infoItem("Org: \(org)")) + sub.addItem(infoItem("Org: \(org)")) } - submenu.addItem(infoItem("Path: \(repo.path)")) - submenu.addItem(NSMenuItem.separator()) + if let ws = workspaceInfo { + sub.addItem(infoItem("Workspace root: \(ws.root)")) + sub.addItem(infoItem("Orgs in workspace: \(ws.orgs.count)")) + if !ws.orgs.isEmpty { + let orgsItem = NSMenuItem(title: "All orgs in workspace", + action: nil, keyEquivalent: "") + let orgsSubmenu = NSMenu() + for name in ws.orgs.sorted() { + let marker = (name == repo.org) ? "\u{2713} " : " " + orgsSubmenu.addItem(infoItem("\(marker)\(name)")) + } + orgsItem.submenu = orgsSubmenu + sub.addItem(orgsItem) + } + } + if sub.items.isEmpty { + sub.addItem(infoItem("(no organization context)")) + } + return sub + } - submenu.addItem(infoItem("Branch: \(repo.currentBranch)")) + private static func repoSelfSubmenu(repo: FinderRepoStatus) -> NSMenu { + let sub = NSMenu() + sub.addItem(infoItem("Status: \(badgeMeaning(repo.badge))")) + sub.addItem(infoItem("Path: \(repo.path)")) + sub.addItem(infoItem("Branch: \(repo.currentBranch)")) if let defaultBranch = repo.defaultBranch, defaultBranch != repo.currentBranch { - submenu.addItem(infoItem("Default: \(defaultBranch)")) + sub.addItem(infoItem("Default: \(defaultBranch)")) } - if repo.ahead > 0 || repo.behind > 0 { - submenu.addItem(infoItem("Ahead: \(repo.ahead) | Behind: \(repo.behind)")) + sub.addItem(infoItem("Ahead: \(repo.ahead) | Behind: \(repo.behind)")) } - - submenu.addItem(infoItem("Commits: \(repo.commitCount)")) - submenu.addItem(infoItem( + sub.addItem(infoItem("Commits: \(repo.commitCount)")) + sub.addItem(infoItem( "Staged: \(repo.stagedCount) | Unstaged: \(repo.unstagedCount)" )) if repo.untrackedCount > 0 { - submenu.addItem(infoItem("Untracked: \(repo.untrackedCount)")) + sub.addItem(infoItem("Untracked: \(repo.untrackedCount)")) } if repo.stashCount > 0 { - submenu.addItem(infoItem("Stashes: \(repo.stashCount)")) + sub.addItem(infoItem("Stashes: \(repo.stashCount)")) } - if repo.hasImportantIgnoredFiles { let patterns = repo.importantIgnoredFiles ?? [] let warnTitle = "\u{26A0} Important ignored files (\(patterns.count))" @@ -106,87 +140,118 @@ enum ContextMenuBuilder { } else { warnItem.isEnabled = false } - submenu.addItem(warnItem) + sub.addItem(warnItem) } + return sub + } + private static func repoDetailsSubmenu(repo: FinderRepoStatus) -> NSMenu { + let sub = NSMenu() if !repo.branches.isEmpty { - submenu.addItem(NSMenuItem.separator()) let label = repo.allBranchesSynced ? "Branches \(repo.branches.count) (\u{2713} all synced)" : "Branches \(repo.branches.count) (some out of sync)" - submenu.addItem(branchesItem(title: label, branches: repo.branches, - currentBranch: repo.currentBranch)) + sub.addItem(branchesItem(title: label, branches: repo.branches, + currentBranch: repo.currentBranch)) } - if !repo.remotes.isEmpty { - submenu.addItem(remotesItem(remotes: repo.remotes)) + sub.addItem(remotesItem(remotes: repo.remotes)) } - if !repo.worktrees.isEmpty { let label = repo.allWorktreesSynced ? "Worktrees \(repo.worktrees.count) (\u{2713} all synced)" : "Worktrees \(repo.worktrees.count) (some out of sync)" - submenu.addItem(worktreesItem(title: label, worktrees: repo.worktrees)) + sub.addItem(worktreesItem(title: label, worktrees: repo.worktrees)) + } + if sub.items.isEmpty { + sub.addItem(infoItem("(no branches, remotes, or worktrees)")) } + return sub + } + + // MARK: - Org root submenu + + private static func orgRoot(org: OrgFolderInfo, + repos: [FinderRepoStatus], + workspaceInfo: FinderWorkspaceInfo?, + timestamp: String?) -> NSMenu { + let root = NSMenu() + + root.addItem(submenuRow(title: "Organization", + content: orgInfoSubmenu(org: org, + workspaceInfo: workspaceInfo))) + root.addItem(submenuRow(title: "Repositories (\(repos.count))", + content: orgAggregateSubmenu(repos: repos))) + root.addItem(submenuRow(title: "Repository list", + content: orgRepoListSubmenu(repos: repos))) if let stamp = formatTimestamp(timestamp) { - submenu.addItem(NSMenuItem.separator()) - submenu.addItem(infoItem("Last scan: \(stamp)")) + root.addItem(NSMenuItem.separator()) + root.addItem(infoItem("Last scan: \(stamp)")) } - submenu.addItem(NSMenuItem.separator()) - submenu.addItem(NSMenuItem( + root.addItem(NSMenuItem.separator()) + root.addItem(NSMenuItem( title: "\u{21BB} Refresh Status", action: #selector(FinderSync.refreshStatus(_:)), keyEquivalent: "" )) - submenu.addItem(NSMenuItem( - title: "Open in Terminal", - action: #selector(FinderSync.openInTerminal(_:)), - keyEquivalent: "" - )) - - return submenu + return root } - // MARK: - Org submenu - - private static func orgSubmenu(for org: OrgFolderInfo, - repos: [FinderRepoStatus], - workspaceInfo: FinderWorkspaceInfo?, - timestamp: String?) -> NSMenu { - let submenu = NSMenu() - - submenu.addItem(infoItem("Owner: \(org.org)")) - submenu.addItem(infoItem("Workspace: \(org.workspace)")) - submenu.addItem(infoItem("Path: \(org.path)")) + private static func orgInfoSubmenu(org: OrgFolderInfo, + workspaceInfo: FinderWorkspaceInfo?) -> NSMenu { + let sub = NSMenu() + sub.addItem(infoItem("Owner: \(org.org)")) + sub.addItem(infoItem("Workspace: \(org.workspace)")) + sub.addItem(infoItem("Path: \(org.path)")) + if let ws = workspaceInfo { + sub.addItem(infoItem("Workspace root: \(ws.root)")) + sub.addItem(infoItem("Orgs in workspace: \(ws.orgs.count)")) + if !ws.orgs.isEmpty { + let orgsItem = NSMenuItem(title: "All orgs in workspace", + action: nil, keyEquivalent: "") + let orgsSubmenu = NSMenu() + for name in ws.orgs.sorted() { + let marker = (name == org.org) ? "\u{2713} " : " " + orgsSubmenu.addItem(infoItem("\(marker)\(name)")) + } + orgsItem.submenu = orgsSubmenu + sub.addItem(orgsItem) + } + } + return sub + } - submenu.addItem(NSMenuItem.separator()) + private static func orgAggregateSubmenu(repos: [FinderRepoStatus]) -> NSMenu { + let sub = NSMenu() + if repos.isEmpty { + sub.addItem(infoItem("(no repositories)")) + return sub + } let counts = badgeCounts(for: repos) - submenu.addItem(infoItem("Repos: \(repos.count)")) - if !repos.isEmpty { - submenu.addItem(infoItem( - "\u{1F7E2} \(counts.green) | \u{1F535} \(counts.blue) | " - + "\u{1F7E0} \(counts.orange) | \u{1F534} \(counts.red)" - )) - } + sub.addItem(infoItem("Repos: \(repos.count)")) + sub.addItem(infoItem( + "\u{1F7E2} \(counts.green) | \u{1F535} \(counts.blue) | " + + "\u{1F7E0} \(counts.orange) | \u{1F534} \(counts.red)" + )) let totals = aggregate(repos: repos) - submenu.addItem(infoItem("Total commits: \(totals.commits)")) + sub.addItem(infoItem("Total commits: \(totals.commits)")) if totals.staged > 0 || totals.unstaged > 0 || totals.untracked > 0 { - submenu.addItem(infoItem( + sub.addItem(infoItem( "Uncommitted \u{2014} staged: \(totals.staged), " + "unstaged: \(totals.unstaged), untracked: \(totals.untracked)" )) } if totals.ahead > 0 || totals.behind > 0 { - submenu.addItem(infoItem( + sub.addItem(infoItem( "Total ahead: \(totals.ahead) | behind: \(totals.behind)" )) } if totals.stashes > 0 { - submenu.addItem(infoItem("Total stashes: \(totals.stashes)")) + sub.addItem(infoItem("Total stashes: \(totals.stashes)")) } let secretRepos = repos.filter { $0.hasImportantIgnoredFiles } @@ -198,61 +263,28 @@ enum ContextMenuBuilder { warnSubmenu.addItem(infoItem(repoBasename(r))) } warnItem.submenu = warnSubmenu - submenu.addItem(warnItem) - } - - if !repos.isEmpty { - submenu.addItem(NSMenuItem.separator()) - submenu.addItem(reposItem(repos: repos)) - } - - if let ws = workspaceInfo { - submenu.addItem(NSMenuItem.separator()) - submenu.addItem(infoItem("Workspace root: \(ws.root)")) - submenu.addItem(infoItem("Orgs in workspace: \(ws.orgs.count)")) - if !ws.orgs.isEmpty { - let orgsItem = NSMenuItem(title: "All orgs in workspace", - action: nil, keyEquivalent: "") - let orgsSubmenu = NSMenu() - for name in ws.orgs.sorted() { - let marker = (name == org.org) ? "\u{2713} " : " " - orgsSubmenu.addItem(infoItem("\(marker)\(name)")) - } - orgsItem.submenu = orgsSubmenu - submenu.addItem(orgsItem) - } - } - - if let stamp = formatTimestamp(timestamp) { - submenu.addItem(NSMenuItem.separator()) - submenu.addItem(infoItem("Last scan: \(stamp)")) + sub.addItem(warnItem) } - - submenu.addItem(NSMenuItem.separator()) - submenu.addItem(NSMenuItem( - title: "\u{21BB} Refresh Status", - action: #selector(FinderSync.refreshStatus(_:)), - keyEquivalent: "" - )) - return submenu + return sub } - // MARK: - Sub-submenus - - private static func reposItem(repos: [FinderRepoStatus]) -> NSMenuItem { - let item = NSMenuItem(title: "Repos (\(repos.count))", - action: nil, keyEquivalent: "") + private static func orgRepoListSubmenu(repos: [FinderRepoStatus]) -> NSMenu { let sub = NSMenu() + if repos.isEmpty { + sub.addItem(infoItem("(no repositories)")) + return sub + } for r in repos.sorted(by: { repoBasename($0) < repoBasename($1) }) { let line = "\(badgeEmoji(r.badge)) \(repoBasename(r)) [\(r.currentBranch)]" let row = NSMenuItem(title: line, action: nil, keyEquivalent: "") row.submenu = repoMiniSubmenu(for: r) sub.addItem(row) } - item.submenu = sub - return item + return sub } + // MARK: - Per-repo mini submenu (used inside the org repo list) + private static func repoMiniSubmenu(for repo: FinderRepoStatus) -> NSMenu { let sub = NSMenu() sub.addItem(infoItem("Status: \(badgeMeaning(repo.badge))")) @@ -282,6 +314,8 @@ enum ContextMenuBuilder { return sub } + // MARK: - Branches / Remotes / Worktrees rows + private static func branchesItem(title: String, branches: [FinderBranchInfo], currentBranch: String) -> NSMenuItem { let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") @@ -304,14 +338,14 @@ enum ContextMenuBuilder { } let row = NSMenuItem(title: "\(checkmark)\(branch.name) \(syncStatus)", action: nil, keyEquivalent: "") - row.isEnabled = false if let upstream = branch.upstream { let detail = NSMenu() detail.addItem(infoItem("Upstream: \(upstream)")) detail.addItem(infoItem("Ahead: \(branch.ahead) | Behind: \(branch.behind)")) detail.addItem(infoItem(branch.synced ? "Synced" : "Out of sync")) row.submenu = detail - row.isEnabled = true + } else { + row.isEnabled = false } sub.addItem(row) } @@ -345,6 +379,30 @@ enum ContextMenuBuilder { // MARK: - Helpers + private static func submenuRow(title: String, content: NSMenu) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.submenu = content + return item + } + + private static func badgeEmoji(_ badge: Badge) -> String { + switch badge { + case .green: return "\u{1F7E2}" // green circle + case .blue: return "\u{1F535}" // blue circle + case .orange: return "\u{1F7E0}" // orange circle + case .red: return "\u{1F534}" // red circle + } + } + + private static func badgeMeaning(_ badge: Badge) -> String { + switch badge { + case .green: return "Synced" + case .blue: return "Has Local Config" + case .orange: return "Partially Synced" + case .red: return "Uncommitted Changes" + } + } + private struct BadgeCounts { var green = 0 var blue = 0 diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index 5340ef6..a128352 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -102,8 +102,12 @@ class FinderSync: FIFinderSync { let timestamp = status?.timestamp if let repoStatus = statusReader.repoStatus(forPath: path) { + let workspaceInfo = repoStatus.workspace.flatMap { name in + status?.workspaces.first { $0.name == name } + } return ContextMenuBuilder.build( for: repoStatus, + workspaceInfo: workspaceInfo, timestamp: timestamp, socketClient: socketClient ) From e8618deff75dad9fbd694ce43cf6d61bf443301e Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 02:11:05 +0200 Subject: [PATCH 13/89] Classify owner folders as user/org via GitHub API for distinct badges Add an OwnerType enum on OrgFolderInfo populated by a file-backed OwnerTypeCache at ~/.config/git-same/finder/owner_types.json. The daemon spawns a background classifier on startup that calls GET /users/ for every configured org and the workspace's own username, caches the result, and the periodic scan picks it up on subsequent iterations. Extend the Provider trait with get_owner_type, implement it on the GitHub client, and stub it in the mock. The daemon and socket REFRESH handler both attach the cache to RepoScanService so the annotation is consistent across scan paths. On the Swift side, decode owner_type in OrgFolderInfo, add a new "user" BadgeID, register a teal "U" badge in BadgeManager, and route FinderSync.requestBadgeIdentifier to the user badge when the folder's ownerType is .user. The workspace's configured username is injected into the scan's owner-folder list so a user account clone (e.g. manuelgruber) gets a "U" even when it isn't in the workspace orgs allowlist. Includes an AmbientUpgradeCache and monitored_roots field added alongside so ambient git repos discovered outside a workspace can be upgraded on demand; covered by colocated tests. --- macos/GitSameBadgeSync/BadgeManager.swift | 67 ++++----- macos/GitSameBadgeSync/FinderSync.swift | 9 +- macos/Shared/Constants.swift | 1 + macos/Shared/StatusModels.swift | 15 +- src/api/ambient_upgrade_cache.rs | 47 ++++++ src/api/ambient_upgrade_cache_tests.rs | 65 +++++++++ src/api/mod.rs | 4 + src/api/owner_type_cache.rs | 79 ++++++++++ src/api/owner_type_cache_tests.rs | 33 +++++ src/api/service.rs | 169 ++++++++++++++++++++-- src/api/service_tests.rs | 6 +- src/commands/daemon.rs | 137 +++++++++++++++++- src/config/parser.rs | 98 +++++++++++++ src/config/parser_tests.rs | 33 +++++ src/discovery.rs | 107 ++++++++++++++ src/discovery_tests.rs | 94 ++++++++++++ src/provider/github/client.rs | 18 ++- src/provider/mock.rs | 6 +- src/provider/traits.rs | 7 +- src/types/finder_status.rs | 28 +++- src/types/mod.rs | 2 +- 21 files changed, 965 insertions(+), 60 deletions(-) create mode 100644 src/api/ambient_upgrade_cache.rs create mode 100644 src/api/ambient_upgrade_cache_tests.rs create mode 100644 src/api/owner_type_cache.rs create mode 100644 src/api/owner_type_cache_tests.rs diff --git a/macos/GitSameBadgeSync/BadgeManager.swift b/macos/GitSameBadgeSync/BadgeManager.swift index 954ff2c..31c5fd1 100644 --- a/macos/GitSameBadgeSync/BadgeManager.swift +++ b/macos/GitSameBadgeSync/BadgeManager.swift @@ -11,64 +11,65 @@ enum BadgeManager { let controller = FIFinderSyncController.default() controller.setBadgeImage( - dotImage(color: .systemGreen), + labeledBadge(text: "R", color: .systemGreen), label: "Synced", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.green ) controller.setBadgeImage( - dotImage(color: .systemBlue), + labeledBadge(text: "R", color: .systemBlue), label: "Has Local Config", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.blue ) controller.setBadgeImage( - dotImage(color: .systemOrange), + labeledBadge(text: "R", color: .systemOrange), label: "Partially Synced", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.orange ) controller.setBadgeImage( - dotImage(color: .systemRed), + labeledBadge(text: "R", color: .systemRed), label: "Uncommitted Changes", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.red ) controller.setBadgeImage( - orgImage(), + labeledBadge(text: "O", color: .systemPurple), label: "Organization", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.org ) + controller.setBadgeImage( + labeledBadge(text: "U", color: .systemTeal), + label: "User", + forBadgeIdentifier: GitSameBadgeConstants.BadgeID.user + ) } - /// Colored dot badge, drawn via closure so it works without a - /// display context (lockFocus produces zero-pixel layer data in - /// sandboxed extensions). - private static func dotImage(color: NSColor) -> NSImage { - let size = NSSize(width: 16, height: 16) + /// Rounded-rect badge with centered white text on a colored fill. + /// + /// Drawn via NSImage(size:flipped:drawingHandler:) so the image has + /// valid CGImage-backed pixel data in a sandboxed extension where + /// lockFocus produces zero-pixel layers. + private static func labeledBadge(text: String, color: NSColor) -> NSImage { + let size = NSSize(width: 64, height: 64) return NSImage(size: size, flipped: false) { rect in - let circle = NSBezierPath(ovalIn: NSRect(x: 2, y: 2, width: 12, height: 12)) + let inset: CGFloat = 4 + let bodyRect = rect.insetBy(dx: inset, dy: inset) + let body = NSBezierPath(roundedRect: bodyRect, xRadius: 14, yRadius: 14) color.setFill() - circle.fill() - NSColor.white.withAlphaComponent(0.5).setStroke() - circle.lineWidth = 1.0 - circle.stroke() - return true - } - } - - /// Org-folder badge: purple building silhouette with four windows. - private static func orgImage() -> NSImage { - let size = NSSize(width: 16, height: 16) - return NSImage(size: size, flipped: false) { rect in - let body = NSBezierPath( - roundedRect: NSRect(x: 3, y: 2, width: 10, height: 12), - xRadius: 1, - yRadius: 1 - ) - NSColor.systemPurple.setFill() body.fill() + NSColor.white.withAlphaComponent(0.35).setStroke() + body.lineWidth = 2.0 + body.stroke() - NSColor.white.setFill() - for (x, y) in [(5, 9), (9, 9), (5, 5), (9, 5)] { - NSBezierPath(rect: NSRect(x: x, y: y, width: 2, height: 2)).fill() - } + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 44, weight: .heavy), + .foregroundColor: NSColor.white, + ] + let attributed = NSAttributedString(string: text, attributes: attrs) + let textSize = attributed.size() + let origin = NSPoint( + x: rect.midX - textSize.width / 2, + y: rect.midY - textSize.height / 2 + ) + attributed.draw(at: origin) return true } } diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index a128352..7ec0211 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -57,10 +57,11 @@ class FinderSync: FIFinderSync { override func requestBadgeIdentifier(for url: URL) { let path = url.path - if statusReader.isOrgFolder(path: path) { - FIFinderSyncController.default().setBadgeIdentifier( - GitSameBadgeConstants.BadgeID.org, for: url - ) + if let orgFolder = statusReader.orgFolder(forPath: path) { + let badgeID = orgFolder.ownerType == .user + ? GitSameBadgeConstants.BadgeID.user + : GitSameBadgeConstants.BadgeID.org + FIFinderSyncController.default().setBadgeIdentifier(badgeID, for: url) return } diff --git a/macos/Shared/Constants.swift b/macos/Shared/Constants.swift index 92a399b..2c3e690 100644 --- a/macos/Shared/Constants.swift +++ b/macos/Shared/Constants.swift @@ -46,5 +46,6 @@ enum GitSameBadgeConstants { static let orange = "git-orange" static let red = "git-red" static let org = "org" + static let user = "user" } } diff --git a/macos/Shared/StatusModels.swift b/macos/Shared/StatusModels.swift index 0999dde..0b5920e 100644 --- a/macos/Shared/StatusModels.swift +++ b/macos/Shared/StatusModels.swift @@ -75,11 +75,24 @@ struct FinderRepoStatus: Codable { } } -/// Organization folder inside a workspace. +/// Classification of the account that owns an org/user folder. +enum OwnerType: String, Codable { + case user + case organization + case unknown +} + +/// Organization or user folder inside a workspace. struct OrgFolderInfo: Codable { let path: String let org: String let workspace: String + let ownerType: OwnerType? + + enum CodingKeys: String, CodingKey { + case path, org, workspace + case ownerType = "owner_type" + } } /// Workspace summary. diff --git a/src/api/ambient_upgrade_cache.rs b/src/api/ambient_upgrade_cache.rs new file mode 100644 index 0000000..37fdda8 --- /dev/null +++ b/src/api/ambient_upgrade_cache.rs @@ -0,0 +1,47 @@ +//! In-memory cache of full-status entries for ambient (non-workspace) repos. +//! +//! Ambient repos start with `Badge::Gray`. When the user right-clicks a gray +//! repo, the extension sends `REFRESH /path` over the socket. The daemon then +//! runs a full `scan_repo` for that path and stores the result here. On every +//! subsequent `scan_all`, ambient entries found in this cache are emitted with +//! their full semantic badge instead of reverting to gray. +//! +//! The cache is not persisted to disk: it lives only for the current daemon +//! run. Restarting the daemon returns all ambient repos to gray until the user +//! opens their context menus again. + +use crate::types::finder_status::FinderRepoStatus; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +pub struct AmbientUpgradeCache { + inner: Arc>>, +} + +impl AmbientUpgradeCache { + pub fn new() -> Self { + Self::default() + } + + pub fn get(&self, path: &Path) -> Option { + self.inner.lock().ok()?.get(path).cloned() + } + + pub fn set(&self, path: PathBuf, status: FinderRepoStatus) { + if let Ok(mut guard) = self.inner.lock() { + guard.insert(path, status); + } + } + + pub fn remove(&self, path: &Path) { + if let Ok(mut guard) = self.inner.lock() { + guard.remove(path); + } + } +} + +#[cfg(test)] +#[path = "ambient_upgrade_cache_tests.rs"] +mod tests; diff --git a/src/api/ambient_upgrade_cache_tests.rs b/src/api/ambient_upgrade_cache_tests.rs new file mode 100644 index 0000000..5197b75 --- /dev/null +++ b/src/api/ambient_upgrade_cache_tests.rs @@ -0,0 +1,65 @@ +use super::*; +use crate::types::finder_status::Badge; +use std::path::PathBuf; + +fn sample_status(path: &str, badge: Badge) -> FinderRepoStatus { + FinderRepoStatus { + path: PathBuf::from(path), + workspace: None, + org: None, + badge, + current_branch: "main".to_string(), + default_branch: None, + commit_count: 0, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: Vec::new(), + all_branches_synced: true, + remotes: Vec::new(), + worktrees: Vec::new(), + all_worktrees_synced: true, + } +} + +#[test] +fn set_then_get_returns_stored_entry() { + let cache = AmbientUpgradeCache::new(); + let path = PathBuf::from("/tmp/repo-a"); + cache.set(path.clone(), sample_status("/tmp/repo-a", Badge::Green)); + + let got = cache.get(&path).unwrap(); + assert_eq!(got.badge, Badge::Green); + assert_eq!(got.path, path); +} + +#[test] +fn get_missing_path_returns_none() { + let cache = AmbientUpgradeCache::new(); + assert!(cache.get(&PathBuf::from("/tmp/not-there")).is_none()); +} + +#[test] +fn remove_drops_entry() { + let cache = AmbientUpgradeCache::new(); + let path = PathBuf::from("/tmp/repo-b"); + cache.set(path.clone(), sample_status("/tmp/repo-b", Badge::Red)); + cache.remove(&path); + assert!(cache.get(&path).is_none()); +} + +#[test] +fn clone_shares_storage() { + let cache = AmbientUpgradeCache::new(); + let handle = cache.clone(); + let path = PathBuf::from("/tmp/shared"); + handle.set(path.clone(), sample_status("/tmp/shared", Badge::Orange)); + + // Original handle sees the write. + assert!(cache.get(&path).is_some()); +} diff --git a/src/api/mod.rs b/src/api/mod.rs index ced91f0..9ba9da9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -13,6 +13,10 @@ //! Consumers hold a `&RepoScanService` and call `scan_all()`, `scan_workspace()`, //! or `scan_repo()` to get structured `FinderStatus` / `FinderRepoStatus` values. +pub mod ambient_upgrade_cache; +pub mod owner_type_cache; pub mod service; +pub use ambient_upgrade_cache::AmbientUpgradeCache; +pub use owner_type_cache::OwnerTypeCache; pub use service::RepoScanService; diff --git a/src/api/owner_type_cache.rs b/src/api/owner_type_cache.rs new file mode 100644 index 0000000..49c9484 --- /dev/null +++ b/src/api/owner_type_cache.rs @@ -0,0 +1,79 @@ +//! File-backed cache of GitHub owner classifications. +//! +//! Used by the Finder badge daemon so that `OrgFolderInfo.owner_type` can be +//! populated without hitting the GitHub API on every scan. + +use crate::errors::Result; +use crate::types::OwnerType; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// JSON-backed map of `name -> OwnerType`. +#[derive(Clone)] +pub struct OwnerTypeCache { + path: PathBuf, + inner: Arc>>, +} + +impl OwnerTypeCache { + /// Creates a new cache at the given path and loads existing entries if + /// the file exists. Missing or unreadable files yield an empty cache + /// without error: classification is best-effort. + pub fn load(path: PathBuf) -> Self { + let map = std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default(); + Self { + path, + inner: Arc::new(Mutex::new(map)), + } + } + + /// Returns the cached owner type, or `None` if not yet classified. + pub fn get(&self, name: &str) -> Option { + self.inner.lock().ok()?.get(name).copied() + } + + /// Inserts or updates a cache entry and persists to disk. + pub fn set(&self, name: &str, owner_type: OwnerType) -> Result<()> { + { + let mut guard = self.inner.lock().unwrap(); + guard.insert(name.to_string(), owner_type); + } + self.persist() + } + + /// Names with no entry in the cache (targets for classification). + pub fn missing<'a>(&self, names: impl IntoIterator) -> Vec { + let guard = self.inner.lock().unwrap(); + names + .into_iter() + .filter(|n| !guard.contains_key(*n)) + .map(|n| n.to_string()) + .collect() + } + + fn persist(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let snapshot: HashMap = self.inner.lock().unwrap().clone(); + let tmp = self.path.with_extension("json.tmp"); + let data = serde_json::to_vec_pretty(&snapshot) + .map_err(|e| std::io::Error::other(format!("serialize cache: {e}")))?; + std::fs::write(&tmp, data)?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } + + /// Default path under the Finder IPC directory. + pub fn default_path(finder_dir: &Path) -> PathBuf { + finder_dir.join("owner_types.json") + } +} + +#[cfg(test)] +#[path = "owner_type_cache_tests.rs"] +mod tests; diff --git a/src/api/owner_type_cache_tests.rs b/src/api/owner_type_cache_tests.rs new file mode 100644 index 0000000..c82c0d2 --- /dev/null +++ b/src/api/owner_type_cache_tests.rs @@ -0,0 +1,33 @@ +use super::*; +use tempfile::TempDir; + +#[test] +fn load_empty_when_file_missing() { + let dir = TempDir::new().unwrap(); + let cache = OwnerTypeCache::load(dir.path().join("owner_types.json")); + assert!(cache.get("nobody").is_none()); +} + +#[test] +fn set_and_get_roundtrips() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("owner_types.json"); + let cache = OwnerTypeCache::load(path.clone()); + cache.set("alice", OwnerType::User).unwrap(); + cache.set("acme", OwnerType::Organization).unwrap(); + assert_eq!(cache.get("alice"), Some(OwnerType::User)); + assert_eq!(cache.get("acme"), Some(OwnerType::Organization)); + + let reloaded = OwnerTypeCache::load(path); + assert_eq!(reloaded.get("alice"), Some(OwnerType::User)); + assert_eq!(reloaded.get("acme"), Some(OwnerType::Organization)); +} + +#[test] +fn missing_returns_unknown_names() { + let dir = TempDir::new().unwrap(); + let cache = OwnerTypeCache::load(dir.path().join("owner_types.json")); + cache.set("known", OwnerType::User).unwrap(); + let todo = cache.missing(["known", "unseen-a", "unseen-b"]); + assert_eq!(todo, vec!["unseen-a".to_string(), "unseen-b".to_string()]); +} diff --git a/src/api/service.rs b/src/api/service.rs index 9ffb6e4..75db97d 100644 --- a/src/api/service.rs +++ b/src/api/service.rs @@ -5,15 +5,17 @@ //! backend and a config, then invoke `scan_all()`, `scan_workspace()`, or //! `scan_repo()`. +use crate::api::{AmbientUpgradeCache, OwnerTypeCache}; use crate::config::{Config, WorkspaceConfig, WorkspaceStore}; -use crate::discovery::DiscoveryOrchestrator; +use crate::discovery::{find_git_repos, DiscoveryOrchestrator}; use crate::errors::Result; use crate::git::GitOperations; use crate::types::finder_status::{ - compute_badge, matches_important_pattern, FinderBranchInfo, FinderRemoteInfo, FinderRepoStatus, - FinderStatus, FinderWorkspaceInfo, FinderWorktreeInfo, OrgFolderInfo, - DEFAULT_IMPORTANT_IGNORED_PATTERNS, + compute_badge, matches_important_pattern, Badge, FinderBranchInfo, FinderRemoteInfo, + FinderRepoStatus, FinderStatus, FinderWorkspaceInfo, FinderWorktreeInfo, OrgFolderInfo, + OwnerType, DEFAULT_IMPORTANT_IGNORED_PATTERNS, }; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use tracing::debug; @@ -24,12 +26,45 @@ use tracing::debug; pub struct RepoScanService<'a> { git: &'a dyn GitOperations, config: &'a Config, + owner_types: Option, + ambient_upgrades: Option, } impl<'a> RepoScanService<'a> { /// Create a new service bound to a git backend and config. pub fn new(git: &'a dyn GitOperations, config: &'a Config) -> Self { - Self { git, config } + Self { + git, + config, + owner_types: None, + ambient_upgrades: None, + } + } + + /// Attach an owner-type cache so scanned org folders are annotated with + /// `OwnerType::User` / `OwnerType::Organization`. + pub fn with_owner_types(mut self, cache: OwnerTypeCache) -> Self { + self.owner_types = Some(cache); + self + } + + /// Attach an ambient-upgrade cache so previously-upgraded ambient repos + /// keep their semantic color across periodic rescans. + pub fn with_ambient_upgrades(mut self, cache: AmbientUpgradeCache) -> Self { + self.ambient_upgrades = Some(cache); + self + } + + /// Clone the attached owner-type cache handle, if any, so socket-handler + /// tasks can reuse the same cache. + pub fn owner_types_clone(&self) -> Option { + self.owner_types.clone() + } + + /// Clone the attached ambient-upgrade cache handle, if any, so socket-handler + /// tasks can reuse it. + pub fn ambient_upgrades_clone(&self) -> Option { + self.ambient_upgrades.clone() } /// Scan all workspaces and build a complete `FinderStatus`. @@ -100,13 +135,29 @@ impl<'a> RepoScanService<'a> { org_names.clone() }; - for org_name in &org_dirs { - let org_path = base_path.join(org_name); - if org_path.exists() { + // Include the configured `username` alongside the orgs list so the + // user's own GitHub login gets a folder entry (and a "U" badge) even + // if it isn't in the org allowlist. + let mut owner_dirs: Vec<(String, OwnerType)> = org_dirs + .iter() + .map(|n| (n.clone(), OwnerType::Unknown)) + .collect(); + if !ws_config.username.is_empty() + && !owner_dirs.iter().any(|(n, _)| n == &ws_config.username) + { + owner_dirs.push((ws_config.username.clone(), OwnerType::User)); + } + + for (owner_name, known_type) in &owner_dirs { + let owner_path = base_path.join(owner_name); + if owner_path.exists() { + let cached = self.owner_types.as_ref().and_then(|c| c.get(owner_name)); + let owner_type = cached.unwrap_or(*known_type); status.org_folders.push(OrgFolderInfo { - path: org_path, - org: org_name.clone(), + path: owner_path, + org: owner_name.clone(), workspace: ws_name.clone(), + owner_type, }); } } @@ -116,9 +167,107 @@ impl<'a> RepoScanService<'a> { status.repos.extend(repos); } + self.populate_ambient(&mut status); + Ok(status) } + /// Append ambient (non-workspace) repos and populate `monitored_roots`. + /// + /// Monitored roots = workspace roots ∪ `finder.scan_roots`. The extension + /// uses this union as its `FIFinderSyncController.directoryURLs`. + fn populate_ambient(&self, status: &mut FinderStatus) { + // Always publish workspace roots so the extension can register them. + for ws in &status.workspaces { + if !status.monitored_roots.contains(&ws.root) { + status.monitored_roots.push(ws.root.clone()); + } + } + + if !self.config.finder.show_ambient { + return; + } + + let scan_roots: Vec = self + .config + .finder + .scan_roots + .iter() + .map(|s| shellexpand::tilde(s).to_string()) + .map(PathBuf::from) + .filter(|p| p.exists()) + .collect(); + + for root in &scan_roots { + let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.clone()); + if !status.monitored_roots.contains(&canonical) { + status.monitored_roots.push(canonical); + } + } + + let exclude: HashSet = self.config.finder.exclude_dirs.iter().cloned().collect(); + let ambient_paths = find_git_repos(&scan_roots, self.config.finder.max_depth, &exclude); + + // Dedupe against already-emitted workspace repos (canonical form). + let workspace_paths: HashSet = status + .repos + .iter() + .map(|r| std::fs::canonicalize(&r.path).unwrap_or_else(|_| r.path.clone())) + .collect(); + + for path in ambient_paths { + if workspace_paths.contains(&path) { + continue; + } + + // Upgraded ambient repos stay upgraded until the daemon exits + // or the repo disappears. + let entry = self + .ambient_upgrades + .as_ref() + .and_then(|cache| { + if !path.join(".git").exists() { + cache.remove(&path); + return None; + } + cache.get(&path) + }) + .unwrap_or_else(|| self.scan_ambient_repo(&path)); + + status.repos.push(entry); + } + } + + /// Build a minimal `FinderRepoStatus` for an ambient (non-workspace) repo. + /// + /// Intentionally performs zero git I/O: the user only needs to *spot* the + /// repo. Full status is computed on demand when the right-click menu + /// triggers a `REFRESH /path`. + pub fn scan_ambient_repo(&self, path: &Path) -> FinderRepoStatus { + FinderRepoStatus { + path: path.to_path_buf(), + workspace: None, + org: None, + badge: Badge::Gray, + current_branch: String::new(), + default_branch: None, + commit_count: 0, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: Vec::new(), + all_branches_synced: true, + remotes: Vec::new(), + worktrees: Vec::new(), + all_worktrees_synced: true, + } + } + /// Scan a single workspace and return its repos with full `FinderRepoStatus`. /// /// Used by: CLI `status` command. diff --git a/src/api/service_tests.rs b/src/api/service_tests.rs index a48a4f9..8008ba6 100644 --- a/src/api/service_tests.rs +++ b/src/api/service_tests.rs @@ -5,7 +5,11 @@ use crate::git::traits::RepoStatus; use crate::types::finder_status::Badge; fn default_config() -> Config { - Config::default() + // Tests should not trigger the ambient $HOME walk, so disable it unless + // a specific test opts in. + let mut cfg = Config::default(); + cfg.finder.show_ambient = false; + cfg } #[test] diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs index 4b4cc2e..a05aa8c 100644 --- a/src/commands/daemon.rs +++ b/src/commands/daemon.rs @@ -8,13 +8,14 @@ //! is just the CLI surface (start/stop/status) plus the daemon loop and //! socket handler that drive the service. -use crate::api::RepoScanService; +use crate::api::{AmbientUpgradeCache, OwnerTypeCache, RepoScanService}; use crate::cli::DaemonArgs; use crate::config::Config; use crate::errors::Result; use crate::git::ShellGit; use crate::ipc::{IpcConfig, StatusFileWriter}; use crate::output::Output; +use crate::types::OwnerType; use std::path::Path; use tracing::{debug, error, info, warn}; @@ -39,19 +40,36 @@ pub async fn run(args: &DaemonArgs, config: &Config, output: &Output) -> Result< let status_writer = StatusFileWriter::new(ipc_config.status_file_path()); let git = ShellGit::new(); - let service = RepoScanService::new(&git, config); + + let owner_types = OwnerTypeCache::load(OwnerTypeCache::default_path(&ipc_config.dir)); + let ambient_upgrades = AmbientUpgradeCache::new(); + let service = RepoScanService::new(&git, config) + .with_owner_types(owner_types.clone()) + .with_ambient_upgrades(ambient_upgrades.clone()); + spawn_owner_classifier(config.clone(), owner_types); + let pid = std::process::id(); // Initial scan let finder_status = service.scan_all(pid)?; status_writer.write(&finder_status)?; + let ambient_count = finder_status + .repos + .iter() + .filter(|r| r.workspace.is_none()) + .count(); + let workspace_count = finder_status.repos.len() - ambient_count; info!( repos = finder_status.repos.len(), + workspace = workspace_count, + ambient = ambient_count, "Initial scan complete, status written" ); output.info(&format!( - "Monitoring {} repos. Status: {}", + "Monitoring {} repos ({} workspace, {} ambient). Status: {}", finder_status.repos.len(), + workspace_count, + ambient_count, ipc_config.status_file_path().display() )); @@ -94,8 +112,10 @@ pub async fn run(args: &DaemonArgs, config: &Config, output: &Output) -> Result< Ok((stream, _)) => { let config_clone = config.clone(); let writer_path = status_writer.path().to_path_buf(); + let owner_clone = service.owner_types_clone(); + let ambient_clone = service.ambient_upgrades_clone(); tokio::spawn(async move { - handle_socket_connection(stream, &config_clone, pid, &writer_path).await; + handle_socket_connection(stream, &config_clone, pid, &writer_path, owner_clone, ambient_clone).await; }); } Err(e) => { @@ -129,6 +149,8 @@ async fn handle_socket_connection( config: &Config, pid: u32, status_path: &Path, + owner_types: Option, + ambient_upgrades: Option, ) { use crate::ipc::unix_socket::DaemonCommand; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -148,13 +170,27 @@ async fn handle_socket_connection( let cmd = DaemonCommand::parse(&line); let git = ShellGit::new(); - let service = RepoScanService::new(&git, config); + let mut service = RepoScanService::new(&git, config); + if let Some(cache) = owner_types { + service = service.with_owner_types(cache); + } + if let Some(cache) = ambient_upgrades.clone() { + service = service.with_ambient_upgrades(cache); + } let response = match cmd { DaemonCommand::Ping => "PONG\n".to_string(), DaemonCommand::RefreshAll | DaemonCommand::Refresh(_) => { + // If the client asked to refresh a specific path, run the full + // scan for it first and store the upgraded entry in the ambient + // cache. The subsequent `scan_all` will pick it up automatically. if let DaemonCommand::Refresh(ref path) = cmd { - debug!(path = %path.display(), "Refresh requested"); + let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()); + debug!(path = %canonical.display(), "Refresh requested"); + let upgraded = service.scan_repo(&canonical, None, None); + if let Some(cache) = &ambient_upgrades { + cache.set(canonical, upgraded); + } } match service.scan_all(pid) { Ok(status) => { @@ -252,6 +288,95 @@ fn stop_daemon(ipc_config: &IpcConfig, _output: &Output) -> Result<()> { Ok(()) } +/// Spawn a background task that classifies every org folder name in the +/// workspace config as User or Organization via the GitHub API and persists +/// the result in `OwnerTypeCache`. Subsequent periodic scans pick up the new +/// classifications as the cache fills. +fn spawn_owner_classifier(config: Config, cache: OwnerTypeCache) { + tokio::spawn(async move { + let names = collect_owner_names(&config); + let missing = cache.missing(names.iter().map(|s| s.as_str())); + if missing.is_empty() { + debug!("Owner type cache already populated, skipping classification"); + return; + } + + let token = match crate::auth::gh_cli::get_token() { + Ok(t) => t, + Err(e) => { + warn!(error = %e, "Owner classification skipped: gh auth token unavailable"); + return; + } + }; + let ws_provider = crate::config::WorkspaceProvider::default(); + let provider = match crate::provider::create_provider(&ws_provider, &token) { + Ok(p) => p, + Err(e) => { + warn!(error = %e, "Owner classification skipped: provider init failed"); + return; + } + }; + + info!( + count = missing.len(), + "Classifying owner types via GitHub API" + ); + for name in &missing { + match provider.get_owner_type(name).await { + Ok(ot) => { + if let Err(e) = cache.set(name, ot) { + warn!(name = %name, error = %e, "Failed to persist owner type"); + } else { + debug!(name = %name, owner_type = ?ot, "Classified owner"); + } + } + Err(e) => { + debug!(name = %name, error = %e, "Owner classification failed, leaving unknown"); + // Cache a "last tried" marker to avoid retrying every scan + let _ = cache.set(name, OwnerType::Unknown); + } + } + } + info!("Owner classification complete"); + }); +} + +/// Collect all unique top-level folder names (orgs + users) from every +/// configured workspace. Mirrors the scanning logic in `RepoScanService`. +fn collect_owner_names(config: &Config) -> Vec { + use std::collections::BTreeSet; + let mut names: BTreeSet = BTreeSet::new(); + + for ws_path in &config.workspaces { + let expanded = shellexpand::tilde(ws_path).to_string(); + let root = std::path::PathBuf::from(&expanded); + if !root.exists() { + continue; + } + let ws_config = match crate::config::WorkspaceStore::load(&root) { + Ok(ws) => ws, + Err(_) => continue, + }; + let base_path = ws_config.expanded_base_path(); + if !ws_config.orgs.is_empty() { + names.extend(ws_config.orgs.iter().cloned()); + } else if let Ok(entries) = std::fs::read_dir(&base_path) { + for e in entries.flatten() { + if let Some(n) = e.file_name().to_str() { + if !n.starts_with('.') && e.file_type().map(|t| t.is_dir()).unwrap_or(false) { + names.insert(n.to_string()); + } + } + } + } + if !ws_config.username.is_empty() { + names.insert(ws_config.username.clone()); + } + } + + names.into_iter().collect() +} + /// Check if a process with the given PID is alive. fn is_process_alive(pid: u32) -> bool { // Use `kill -0` via shell — avoids libc dependency diff --git a/src/config/parser.rs b/src/config/parser.rs index c0a8cf2..210ca54 100644 --- a/src/config/parser.rs +++ b/src/config/parser.rs @@ -46,6 +46,78 @@ pub struct FilterOptions { pub exclude_repos: Vec, } +/// Finder-badge discovery configuration. +/// +/// Controls how the daemon finds ambient git repositories outside any +/// configured workspace. Ambient repos get a neutral gray badge until the +/// user opens their context menu, which triggers an on-demand upgrade. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FinderConfig { + /// Roots to walk when looking for ambient git repos. Defaults to `["~"]` + /// (the user's home directory). + #[serde(default = "default_finder_scan_roots")] + pub scan_roots: Vec, + + /// Maximum directory depth to descend during the ambient walk. + #[serde(default = "default_finder_max_depth")] + pub max_depth: usize, + + /// Directory names to skip during the ambient walk. Load-bearing for + /// performance: without entries like `node_modules` and `target`, the + /// scan times balloon on developer machines. + #[serde(default = "default_finder_excludes")] + pub exclude_dirs: Vec, + + /// Feature flag: when false, the daemon skips the ambient scan and only + /// workspace repos get badged (pre-change behaviour). + #[serde(default = "default_true")] + pub show_ambient: bool, +} + +impl Default for FinderConfig { + fn default() -> Self { + Self { + scan_roots: default_finder_scan_roots(), + max_depth: default_finder_max_depth(), + exclude_dirs: default_finder_excludes(), + show_ambient: true, + } + } +} + +fn default_finder_scan_roots() -> Vec { + vec!["~".to_string()] +} + +fn default_finder_max_depth() -> usize { + 8 +} + +fn default_finder_excludes() -> Vec { + vec![ + "node_modules".into(), + "target".into(), + "build".into(), + "dist".into(), + "DerivedData".into(), + "Pods".into(), + "Library".into(), + ".cache".into(), + ".cargo".into(), + ".rustup".into(), + ".npm".into(), + ".yarn".into(), + ".venv".into(), + ".Trash".into(), + ".git-same".into(), + ".zsh_sessions".into(), + ] +} + +fn default_true() -> bool { + true +} + /// Sync mode for existing repositories. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "kebab-case")] @@ -105,6 +177,10 @@ pub struct Config { /// Registry of known workspace root paths (tilde-collapsed). #[serde(default)] pub workspaces: Vec, + + /// Finder badge daemon configuration (ambient repo discovery). + #[serde(default)] + pub finder: FinderConfig, } fn default_structure() -> String { @@ -130,6 +206,7 @@ impl Default for Config { clone: ConfigCloneOptions::default(), filters: FilterOptions::default(), workspaces: Vec::new(), + finder: FinderConfig::default(), } } } @@ -250,6 +327,27 @@ include_forks = false # Exclude specific repos # exclude_repos = ["org/repo-to-skip"] + +[finder] +# Show a neutral gray R-badge on every git repository found under +# `scan_roots`, even outside a configured workspace. Right-clicking a gray +# repo upgrades it to the normal color (green/blue/orange/red). +show_ambient = true + +# Roots to walk for ambient repos. "~" expands to your home directory. +# If you change this, update the FinderSync extension entitlements +# (macos/GitSameBadgeSync/GitSameBadgeSync.entitlements) and re-sign. +scan_roots = ["~"] + +# Maximum directory depth for the ambient walk. +max_depth = 8 + +# Directory names skipped during the ambient walk. Critical for performance. +exclude_dirs = [ + "node_modules", "target", "build", "dist", "DerivedData", "Pods", + "Library", ".cache", ".cargo", ".rustup", ".npm", ".yarn", ".venv", + ".Trash", ".git-same", ".zsh_sessions", +] "# } diff --git a/src/config/parser_tests.rs b/src/config/parser_tests.rs index 1b82064..b7d190c 100644 --- a/src/config/parser_tests.rs +++ b/src/config/parser_tests.rs @@ -235,3 +235,36 @@ workspaces = "invalid" let content = std::fs::read_to_string(&path).unwrap(); assert!(content.contains(r#"workspaces = "invalid""#)); } + +#[test] +fn finder_config_defaults() { + let cfg = FinderConfig::default(); + assert_eq!(cfg.scan_roots, vec!["~".to_string()]); + assert_eq!(cfg.max_depth, 8); + assert!(cfg.show_ambient); + assert!(cfg.exclude_dirs.iter().any(|s| s == "node_modules")); + assert!(cfg.exclude_dirs.iter().any(|s| s == "target")); +} + +#[test] +fn finder_config_parses_from_toml() { + let content = r#" +[finder] +show_ambient = false +max_depth = 3 +scan_roots = ["/tmp/repos"] +exclude_dirs = ["custom_skip"] +"#; + let cfg = Config::parse(content).unwrap(); + assert!(!cfg.finder.show_ambient); + assert_eq!(cfg.finder.max_depth, 3); + assert_eq!(cfg.finder.scan_roots, vec!["/tmp/repos".to_string()]); + assert_eq!(cfg.finder.exclude_dirs, vec!["custom_skip".to_string()]); +} + +#[test] +fn finder_config_missing_section_uses_defaults() { + let cfg = Config::parse("concurrency = 4").unwrap(); + assert!(cfg.finder.show_ambient); + assert_eq!(cfg.finder.max_depth, 8); +} diff --git a/src/discovery.rs b/src/discovery.rs index 80132c5..89faa07 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -232,6 +232,113 @@ impl DiscoveryOrchestrator { } } +/// Walks the given roots and returns every git repository root found. +/// +/// A directory is a repo if it contains a `.git` entry (directory for normal +/// repos, or a file for worktree/submodule gitlinks). Once a repo is found we +/// stop descending into it — we only care about repo roots, not files inside. +/// +/// - `max_depth` caps recursion depth (the root itself is depth 0). +/// - `exclude` is matched against directory **names** (not full paths), so +/// passing `"node_modules"` skips every `node_modules/` no matter where it +/// sits. Pass lowercase for case-insensitive matching if needed; current +/// behaviour is exact match. +/// - Symlinks and cycles are handled via a visited-set of canonical paths. +/// - Permission-denied or I/O errors are silently skipped. +/// +/// Returns canonical paths in the order they were discovered, deduplicated. +pub fn find_git_repos( + roots: &[PathBuf], + max_depth: usize, + exclude: &HashSet, +) -> Vec { + let mut found: Vec = Vec::new(); + let mut found_set: HashSet = HashSet::new(); + let mut visited: HashSet = HashSet::new(); + + for root in roots { + let Ok(canonical_root) = std::fs::canonicalize(root) else { + continue; + }; + walk_for_repos( + &canonical_root, + 0, + max_depth, + exclude, + &mut found, + &mut found_set, + &mut visited, + ); + } + + found +} + +fn walk_for_repos( + path: &Path, + depth: usize, + max_depth: usize, + exclude: &HashSet, + found: &mut Vec, + found_set: &mut HashSet, + visited: &mut HashSet, +) { + if !visited.insert(path.to_path_buf()) { + return; + } + + // A repo is identified by the presence of a `.git` entry (dir or file). + // `symlink_metadata` is cheaper than `metadata` and doesn't follow links. + if std::fs::symlink_metadata(path.join(".git")).is_ok() { + if found_set.insert(path.to_path_buf()) { + found.push(path.to_path_buf()); + } + return; // don't descend into a repo's own tree + } + + if depth >= max_depth { + return; + } + + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip excluded names and hidden dirs (hidden dirs rarely contain + // repos we care about; `.git-same`, `.Trash` etc. already excluded + // by name list but this is a belt-and-braces). + if exclude.contains(name_str.as_ref()) { + continue; + } + if name_str.starts_with('.') { + continue; + } + + let child = entry.path(); + let canonical_child = std::fs::canonicalize(&child).unwrap_or(child); + walk_for_repos( + &canonical_child, + depth + 1, + max_depth, + exclude, + found, + found_set, + visited, + ); + } +} + /// Merges discovered repos from multiple providers. pub fn merge_repos(repos_by_provider: Vec<(String, Vec)>) -> Vec<(String, OwnedRepo)> { let mut result = Vec::new(); diff --git a/src/discovery_tests.rs b/src/discovery_tests.rs index 14fb682..07f685a 100644 --- a/src/discovery_tests.rs +++ b/src/discovery_tests.rs @@ -193,3 +193,97 @@ fn test_to_discovery_options() { assert!(!options.include_forks); assert_eq!(options.org_filter, vec!["org1", "org2"]); } + +fn touch(path: &Path) { + std::fs::write(path, "").unwrap(); +} + +fn make_repo(dir: &Path) { + std::fs::create_dir_all(dir.join(".git")).unwrap(); +} + +#[test] +fn find_git_repos_detects_root_and_nested_repos() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join("top")); + make_repo(&tmp.path().join("nested/inner")); + std::fs::create_dir_all(tmp.path().join("not-a-repo")).unwrap(); + + let roots = vec![tmp.path().to_path_buf()]; + let exclude: HashSet = HashSet::new(); + let found = find_git_repos(&roots, 5, &exclude); + + assert!(found.iter().any(|p| p.ends_with("top"))); + assert!(found.iter().any(|p| p.ends_with("inner"))); + assert!(!found.iter().any(|p| p.ends_with("not-a-repo"))); +} + +#[test] +fn find_git_repos_stops_descending_inside_a_repo() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join("outer")); + // A nested ".git" inside an already-detected repo should NOT produce a + // second hit — we stop descending as soon as we see a repo root. + make_repo(&tmp.path().join("outer/sub")); + + let found = find_git_repos(&[tmp.path().to_path_buf()], 5, &HashSet::new()); + + let outer_hits = found + .iter() + .filter(|p| p.ends_with("outer") || p.ends_with("sub")) + .count(); + assert_eq!(outer_hits, 1); +} + +#[test] +fn find_git_repos_honors_exclude_list() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join("node_modules/leaky-lib")); + make_repo(&tmp.path().join("keep-me")); + + let mut exclude = HashSet::new(); + exclude.insert("node_modules".to_string()); + + let found = find_git_repos(&[tmp.path().to_path_buf()], 5, &exclude); + + assert!(found.iter().any(|p| p.ends_with("keep-me"))); + assert!(!found.iter().any(|p| p.ends_with("leaky-lib"))); +} + +#[test] +fn find_git_repos_respects_max_depth() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join("a/b/c/deep-repo")); + + // Depth 2 means we can descend "a" → "b" but not into "c". + let found = find_git_repos(&[tmp.path().to_path_buf()], 2, &HashSet::new()); + assert!(found.is_empty()); + + // Depth 4 reaches it. + let found = find_git_repos(&[tmp.path().to_path_buf()], 4, &HashSet::new()); + assert!(found.iter().any(|p| p.ends_with("deep-repo"))); +} + +#[test] +fn find_git_repos_handles_gitlink_file() { + // Submodule/worktree gitlink: `.git` is a regular file, not a directory. + let tmp = TempDir::new().unwrap(); + let submodule = tmp.path().join("submodule"); + std::fs::create_dir_all(&submodule).unwrap(); + touch(&submodule.join(".git")); + + let found = find_git_repos(&[tmp.path().to_path_buf()], 3, &HashSet::new()); + assert!(found.iter().any(|p| p.ends_with("submodule"))); +} + +#[test] +fn find_git_repos_skips_hidden_directories() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join(".hidden/repo")); + + let found = find_git_repos(&[tmp.path().to_path_buf()], 5, &HashSet::new()); + assert!( + !found.iter().any(|p| p.ends_with("repo")), + "hidden dirs should not be traversed" + ); +} diff --git a/src/provider/github/client.rs b/src/provider/github/client.rs index 77b4ecc..daba63c 100644 --- a/src/provider/github/client.rs +++ b/src/provider/github/client.rs @@ -9,7 +9,7 @@ use super::pagination::fetch_all_pages; use super::GITHUB_API_URL; use crate::errors::ProviderError; use crate::provider::traits::*; -use crate::types::{Org, OwnedRepo, ProviderKind, Repo}; +use crate::types::{Org, OwnedRepo, OwnerType, ProviderKind, Repo}; /// Default timeout for API requests in seconds. const DEFAULT_TIMEOUT_SECS: u64 = 60; @@ -267,6 +267,22 @@ impl Provider for GitHubProvider { repo.clone_url.clone() } } + + async fn get_owner_type(&self, name: &str) -> Result { + #[derive(serde::Deserialize)] + struct UserOrOrg { + #[serde(rename = "type")] + kind: String, + } + + let url = self.api_url(&format!("/users/{}", name)); + let payload: UserOrOrg = self.get(&url).await?; + Ok(match payload.kind.as_str() { + "User" => OwnerType::User, + "Organization" => OwnerType::Organization, + _ => OwnerType::Unknown, + }) + } } #[cfg(test)] diff --git a/src/provider/mock.rs b/src/provider/mock.rs index 18478eb..c051a78 100644 --- a/src/provider/mock.rs +++ b/src/provider/mock.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use super::traits::*; use crate::errors::ProviderError; -use crate::types::{Org, OwnedRepo, ProviderKind, Repo}; +use crate::types::{Org, OwnedRepo, OwnerType, ProviderKind, Repo}; /// A mock provider that can be configured with predefined responses. pub struct MockProvider { @@ -239,6 +239,10 @@ impl Provider for MockProvider { repo.clone_url.clone() } } + + async fn get_owner_type(&self, _name: &str) -> Result { + Ok(OwnerType::Organization) + } } #[cfg(test)] diff --git a/src/provider/traits.rs b/src/provider/traits.rs index b4612a4..0b84b83 100644 --- a/src/provider/traits.rs +++ b/src/provider/traits.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use crate::errors::ProviderError; -use crate::types::{Org, OwnedRepo, ProviderKind, Repo}; +use crate::types::{Org, OwnedRepo, OwnerType, ProviderKind, Repo}; /// Authentication credentials for a provider. #[derive(Debug, Clone)] @@ -207,6 +207,11 @@ pub trait Provider: Send + Sync { /// Returns the clone URL for a repo (SSH or HTTPS based on preference). fn get_clone_url(&self, repo: &Repo, prefer_ssh: bool) -> String; + + /// Classifies whether the given account name is a personal user or an + /// organization. Used by the Finder badge daemon to pick between "U" and + /// "O" badges on workspace folders. + async fn get_owner_type(&self, name: &str) -> Result; } #[cfg(test)] diff --git a/src/types/finder_status.rs b/src/types/finder_status.rs index 8de4c71..13433d2 100644 --- a/src/types/finder_status.rs +++ b/src/types/finder_status.rs @@ -10,6 +10,8 @@ use std::path::PathBuf; /// Badge color indicating repository health. /// /// Priority order: Red > Orange > Blue > Green. +/// `Gray` is reserved for ambient (non-workspace) repos that haven't been +/// classified yet. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Badge { @@ -25,6 +27,9 @@ pub enum Badge { /// Staged, unstaged, untracked, or unpushed commits. /// DO NOT delete — uncommitted work or unpushed commits would be lost. Red, + /// Ambient git repo discovered outside any configured workspace. + /// Upgraded to a semantic color on demand (right-click → REFRESH /path). + Gray, } /// Branch sync status in the context menu. @@ -83,12 +88,27 @@ pub struct FinderRepoStatus { pub all_worktrees_synced: bool, } -/// An organization folder inside a git-same workspace. +/// Classification of a GitHub account that owns repositories. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum OwnerType { + /// Personal GitHub account. + User, + /// GitHub Organization account. + Organization, + /// Not yet classified (cache miss) or classification failed. + #[default] + Unknown, +} + +/// An organization or user folder inside a git-same workspace. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OrgFolderInfo { pub path: PathBuf, pub org: String, pub workspace: String, + #[serde(default)] + pub owner_type: OwnerType, } /// Workspace summary for the status file. @@ -114,6 +134,11 @@ pub struct FinderStatus { pub repos: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub org_folders: Vec, + /// Union of workspace roots and ambient scan roots. The FinderSync + /// extension registers these as `FIFinderSyncController.directoryURLs` + /// so Finder knows which folders to ask about. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub monitored_roots: Vec, } impl FinderStatus { @@ -130,6 +155,7 @@ impl FinderStatus { custom_folders: Vec::new(), repos: Vec::new(), org_folders: Vec::new(), + monitored_roots: Vec::new(), } } } diff --git a/src/types/mod.rs b/src/types/mod.rs index 3eefccf..fdd75b2 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -17,7 +17,7 @@ mod repo; pub use finder_status::{ Badge, FinderBranchInfo, FinderRemoteInfo, FinderRepoStatus, FinderStatus, FinderWorkspaceInfo, - FinderWorktreeInfo, OrgFolderInfo, + FinderWorktreeInfo, OrgFolderInfo, OwnerType, }; pub use provider::ProviderKind; pub use repo::{ActionPlan, OpResult, OpSummary, Org, OwnedRepo, Repo, SkippedRepo}; From ddabff8c4a41f3e479ad9335b28d144bffb4b715 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 02:15:22 +0200 Subject: [PATCH 14/89] Add gray ambient badge case to Swift and ambient-repo tests Extend the Swift Badge enum and BadgeID set to handle the new gray ambient case that already exists in the Rust schema, wire the gray branch through FinderSync's request handler, ContextMenuBuilder's aggregate counts and labels, and decode the monitored_roots field so the extension can register ambient scan roots alongside workspace roots. Add three Rust tests for the ambient pipeline: scan_ambient_repo produces a minimal gray entry, scan_all emits gray entries under configured finder.scan_roots, and AmbientUpgradeCache preserves an upgraded semantic color across scans. --- macos/GitSameBadgeSync/BadgeManager.swift | 5 + .../GitSameBadgeSync/ContextMenuBuilder.swift | 11 ++ macos/GitSameBadgeSync/FinderSync.swift | 99 +++++++++++++--- .../GitSameBadgeSync.entitlements | 7 ++ macos/Shared/Constants.swift | 1 + macos/Shared/StatusModels.swift | 10 ++ src/api/service_tests.rs | 107 +++++++++++++++++- 7 files changed, 224 insertions(+), 16 deletions(-) diff --git a/macos/GitSameBadgeSync/BadgeManager.swift b/macos/GitSameBadgeSync/BadgeManager.swift index 31c5fd1..8940fac 100644 --- a/macos/GitSameBadgeSync/BadgeManager.swift +++ b/macos/GitSameBadgeSync/BadgeManager.swift @@ -30,6 +30,11 @@ enum BadgeManager { label: "Uncommitted Changes", forBadgeIdentifier: GitSameBadgeConstants.BadgeID.red ) + controller.setBadgeImage( + labeledBadge(text: "R", color: .systemGray), + label: "Git Repository", + forBadgeIdentifier: GitSameBadgeConstants.BadgeID.gray + ) controller.setBadgeImage( labeledBadge(text: "O", color: .systemPurple), label: "Organization", diff --git a/macos/GitSameBadgeSync/ContextMenuBuilder.swift b/macos/GitSameBadgeSync/ContextMenuBuilder.swift index 2f6444d..5340b4d 100644 --- a/macos/GitSameBadgeSync/ContextMenuBuilder.swift +++ b/macos/GitSameBadgeSync/ContextMenuBuilder.swift @@ -12,6 +12,13 @@ enum ContextMenuBuilder { workspaceInfo: FinderWorkspaceInfo?, timestamp: String?, socketClient: SocketClient) -> NSMenu { + // Ambient repos ship with `.gray` and no git details. Fire a targeted + // REFRESH so the daemon runs a full scan_repo on this path; the + // StatusReader file watcher will then replace the gray badge with a + // semantic color within the next Finder tick. + if repo.badge == .gray { + socketClient.send("REFRESH \(repo.path)") { _ in } + } let menu = NSMenu(title: "GitSameBadge") menu.addItem(parentItem(badge: repo.badge, submenu: repoRoot(repo: repo, @@ -391,6 +398,7 @@ enum ContextMenuBuilder { case .blue: return "\u{1F535}" // blue circle case .orange: return "\u{1F7E0}" // orange circle case .red: return "\u{1F534}" // red circle + case .gray: return "\u{26AB}" // black/gray circle } } @@ -400,6 +408,7 @@ enum ContextMenuBuilder { case .blue: return "Has Local Config" case .orange: return "Partially Synced" case .red: return "Uncommitted Changes" + case .gray: return "Git Repository" } } @@ -408,6 +417,7 @@ enum ContextMenuBuilder { var blue = 0 var orange = 0 var red = 0 + var gray = 0 } private static func badgeCounts(for repos: [FinderRepoStatus]) -> BadgeCounts { @@ -418,6 +428,7 @@ enum ContextMenuBuilder { case .blue: counts.blue += 1 case .orange: counts.orange += 1 case .red: counts.red += 1 + case .gray: counts.gray += 1 } } return counts diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index 7ec0211..cc08db7 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -12,6 +12,9 @@ class FinderSync: FIFinderSync { let statusReader = StatusReader.shared let socketClient = SocketClient() + private var lastRefreshRequest: [String: Date] = [:] + private static let refreshThrottle: TimeInterval = 10.0 + override init() { super.init() os_log("FinderSync init entered", log: gsbLog, type: .default) @@ -20,6 +23,7 @@ class FinderSync: FIFinderSync { statusReader.onStatusUpdate = { [weak self] in self?.updateMonitoredDirectories() + self?.prewarmBadges() } statusReader.startWatching() @@ -28,6 +32,7 @@ class FinderSync: FIFinderSync { // directoryURLs stays empty until the daemon next rewrites the file, // so Finder never asks us for badges. updateMonitoredDirectories() + prewarmBadges() } // MARK: - Monitored Directories @@ -39,11 +44,20 @@ class FinderSync: FIFinderSync { } var urls = Set() - for workspace in status.workspaces { - urls.insert(URL(fileURLWithPath: workspace.root)) - } - for folder in status.customFolders ?? [] { - urls.insert(URL(fileURLWithPath: folder)) + // Prefer the daemon-provided monitored_roots (workspace roots ∪ ambient + // scan roots). Fall back to the workspace+custom_folders union for + // older daemons that predate that field. + if let roots = status.monitoredRoots, !roots.isEmpty { + for root in roots { + urls.insert(URL(fileURLWithPath: root)) + } + } else { + for workspace in status.workspaces { + urls.insert(URL(fileURLWithPath: workspace.root)) + } + for folder in status.customFolders ?? [] { + urls.insert(URL(fileURLWithPath: folder)) + } } FIFinderSyncController.default().directoryURLs = urls @@ -58,23 +72,78 @@ class FinderSync: FIFinderSync { let path = url.path if let orgFolder = statusReader.orgFolder(forPath: path) { - let badgeID = orgFolder.ownerType == .user + let finalID = orgFolder.ownerType == .user ? GitSameBadgeConstants.BadgeID.user : GitSameBadgeConstants.BadgeID.org - FIFinderSyncController.default().setBadgeIdentifier(badgeID, for: url) + applyBadge(finalID: finalID, for: url) return } if let repoStatus = statusReader.repoStatus(forPath: path) { - let badgeID: String - switch repoStatus.badge { - case .green: badgeID = GitSameBadgeConstants.BadgeID.green - case .blue: badgeID = GitSameBadgeConstants.BadgeID.blue - case .orange: badgeID = GitSameBadgeConstants.BadgeID.orange - case .red: badgeID = GitSameBadgeConstants.BadgeID.red - } - FIFinderSyncController.default().setBadgeIdentifier(badgeID, for: url) + applyBadge(finalID: badgeID(for: repoStatus.badge), for: url) + return + } + + // Unknown path inside a monitored directory: nudge the daemon so the + // real color arrives on its next scan instead of waiting up to 30s. + requestRefresh(path: path) + } + + /// Render grey "R" synchronously so Finder has something to draw right + /// away, then swap in the real color on the next runloop tick. When the + /// final badge is already grey (daemon-marked ambient repo), skip the + /// second call. + private func applyBadge(finalID: String, for url: URL) { + let controller = FIFinderSyncController.default() + if finalID == GitSameBadgeConstants.BadgeID.gray { + controller.setBadgeIdentifier(finalID, for: url) + return + } + controller.setBadgeIdentifier(GitSameBadgeConstants.BadgeID.gray, for: url) + DispatchQueue.main.async { + controller.setBadgeIdentifier(finalID, for: url) + } + } + + private func badgeID(for badge: Badge) -> String { + switch badge { + case .green: return GitSameBadgeConstants.BadgeID.green + case .blue: return GitSameBadgeConstants.BadgeID.blue + case .orange: return GitSameBadgeConstants.BadgeID.orange + case .red: return GitSameBadgeConstants.BadgeID.red + case .gray: return GitSameBadgeConstants.BadgeID.gray + } + } + + /// Pre-register badges for every known repo and org-folder URL. Finder's + /// first-paint-per-URL pipeline dominates the visible latency, so setting + /// badges before Finder asks lets the UI render them without a blank gap. + private func prewarmBadges() { + guard let status = statusReader.currentStatus else { return } + + for orgFolder in status.orgFolders ?? [] { + let url = URL(fileURLWithPath: orgFolder.path) + let finalID = orgFolder.ownerType == .user + ? GitSameBadgeConstants.BadgeID.user + : GitSameBadgeConstants.BadgeID.org + applyBadge(finalID: finalID, for: url) + } + + for repo in status.repos { + let url = URL(fileURLWithPath: repo.path) + applyBadge(finalID: badgeID(for: repo.badge), for: url) + } + } + + private func requestRefresh(path: String) { + let now = Date() + if let last = lastRefreshRequest[path], + now.timeIntervalSince(last) < Self.refreshThrottle + { + return } + lastRefreshRequest[path] = now + socketClient.send("REFRESH \(path)") { _ in } } // MARK: - Toolbar diff --git a/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements b/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements index 807b075..b2ee9f1 100644 --- a/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements +++ b/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements @@ -1,5 +1,11 @@ + com.apple.security.app-sandbox @@ -9,6 +15,7 @@ /Users/m/.config/git-same/finder/ /Users/m/Manuel-Sun/Engineering/Same-GitHub/ /Users/m/Manuel-Sun/Engineering/Same-SX/ + /Users/m/ diff --git a/macos/Shared/Constants.swift b/macos/Shared/Constants.swift index 2c3e690..7b34cbc 100644 --- a/macos/Shared/Constants.swift +++ b/macos/Shared/Constants.swift @@ -45,6 +45,7 @@ enum GitSameBadgeConstants { static let blue = "git-blue" static let orange = "git-orange" static let red = "git-red" + static let gray = "git-gray" static let org = "org" static let user = "user" } diff --git a/macos/Shared/StatusModels.swift b/macos/Shared/StatusModels.swift index 0b5920e..bc22373 100644 --- a/macos/Shared/StatusModels.swift +++ b/macos/Shared/StatusModels.swift @@ -4,11 +4,16 @@ import Foundation /// Badge color indicating repository health. +/// +/// `gray` marks ambient (non-workspace) repos that haven't been fully scanned +/// yet — they upgrade to a semantic color when the user opens their context +/// menu. enum Badge: String, Codable { case green case blue case orange case red + case gray } /// Branch sync status. @@ -111,6 +116,10 @@ struct FinderStatus: Codable { let customFolders: [String]? let repos: [FinderRepoStatus] let orgFolders: [OrgFolderInfo]? + /// Union of workspace roots and ambient scan roots. The extension uses + /// this as `FIFinderSyncController.directoryURLs` so Finder knows which + /// folders to ask about. + let monitoredRoots: [String]? enum CodingKeys: String, CodingKey { case version, timestamp @@ -119,5 +128,6 @@ struct FinderStatus: Codable { case customFolders = "custom_folders" case repos case orgFolders = "org_folders" + case monitoredRoots = "monitored_roots" } } diff --git a/src/api/service_tests.rs b/src/api/service_tests.rs index 8008ba6..89457db 100644 --- a/src/api/service_tests.rs +++ b/src/api/service_tests.rs @@ -1,8 +1,9 @@ use super::*; +use crate::api::AmbientUpgradeCache; use crate::config::Config; use crate::git::traits::mock::{MockConfig, MockGit}; use crate::git::traits::RepoStatus; -use crate::types::finder_status::Badge; +use crate::types::finder_status::{Badge, FinderRepoStatus}; fn default_config() -> Config { // Tests should not trigger the ambient $HOME walk, so disable it unless @@ -93,3 +94,107 @@ fn test_scan_all_empty_workspaces() { assert!(status.repos.is_empty()); assert!(status.org_folders.is_empty()); } + +#[test] +fn scan_ambient_repo_is_minimal_and_gray() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_ambient_repo(Path::new("/tmp/ambient")); + + assert_eq!(status.badge, Badge::Gray); + assert_eq!(status.path, std::path::PathBuf::from("/tmp/ambient")); + assert!(status.workspace.is_none()); + assert!(status.org.is_none()); + assert_eq!(status.staged_count, 0); + assert_eq!(status.commit_count, 0); + assert!(status.branches.is_empty()); + assert!(status.remotes.is_empty()); +} + +#[test] +fn scan_all_emits_ambient_gray_repos_when_enabled() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("alpha/.git")).unwrap(); + std::fs::create_dir_all(tmp.path().join("beta/.git")).unwrap(); + std::fs::create_dir_all(tmp.path().join("not-a-repo")).unwrap(); + + let mock = MockGit::new(); + let mut cfg = Config::default(); + cfg.finder.show_ambient = true; + cfg.finder.scan_roots = vec![tmp.path().to_string_lossy().to_string()]; + cfg.finder.max_depth = 2; + cfg.finder.exclude_dirs = Vec::new(); + + let service = RepoScanService::new(&mock, &cfg); + let status = service.scan_all(1).unwrap(); + + let gray_count = status + .repos + .iter() + .filter(|r| r.badge == Badge::Gray) + .count(); + assert_eq!(gray_count, 2); + assert!(status + .repos + .iter() + .any(|r| r.path.ends_with("alpha") && r.badge == Badge::Gray)); + assert!(status + .repos + .iter() + .any(|r| r.path.ends_with("beta") && r.badge == Badge::Gray)); + let canonical_tmp = std::fs::canonicalize(tmp.path()).unwrap(); + assert!(status.monitored_roots.iter().any(|p| p == &canonical_tmp)); +} + +#[test] +fn ambient_upgrade_cache_preserves_semantic_color() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("myrepo/.git")).unwrap(); + let repo_path = std::fs::canonicalize(tmp.path().join("myrepo")).unwrap(); + + let mock = MockGit::new(); + let mut cfg = Config::default(); + cfg.finder.show_ambient = true; + cfg.finder.scan_roots = vec![tmp.path().to_string_lossy().to_string()]; + cfg.finder.max_depth = 2; + cfg.finder.exclude_dirs = Vec::new(); + + let upgrades = AmbientUpgradeCache::new(); + // Prime the cache with a Green upgraded entry. + let upgraded = FinderRepoStatus { + path: repo_path.clone(), + workspace: None, + org: None, + badge: Badge::Green, + current_branch: "main".to_string(), + default_branch: None, + commit_count: 42, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: Vec::new(), + all_branches_synced: true, + remotes: Vec::new(), + worktrees: Vec::new(), + all_worktrees_synced: true, + }; + upgrades.set(repo_path.clone(), upgraded); + + let service = RepoScanService::new(&mock, &cfg).with_ambient_upgrades(upgrades); + let status = service.scan_all(1).unwrap(); + + let emitted = status + .repos + .iter() + .find(|r| std::fs::canonicalize(&r.path).unwrap_or(r.path.clone()) == repo_path) + .expect("ambient repo should be emitted"); + assert_eq!(emitted.badge, Badge::Green); + assert_eq!(emitted.commit_count, 42); +} From 50b4a7828ebc18db7b8fc3550adb0f9d51c32575 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 02:31:26 +0200 Subject: [PATCH 15/89] Skip grey R placeholder for O and U badges to avoid letter swap on paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Org and user folders render their own letter (O / U) and do not share the "R" family, so flashing a grey R before painting the final badge would be a letter change rather than a loading hint. Restrict the grey→color flash to green/blue/orange/red (the R colors) and let org, user, and daemon-emitted gray badges render directly. prewarmBadges now sets the final O/U badge for org folders without a grey intermediate and tracks them in coloredURLs so subsequent paints stay steady. --- macos/GitSameBadgeSync/FinderSync.swift | 101 ++++++++++++++++++++---- 1 file changed, 85 insertions(+), 16 deletions(-) diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index cc08db7..fc7efa1 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -15,6 +15,17 @@ class FinderSync: FIFinderSync { private var lastRefreshRequest: [String: Date] = [:] private static let refreshThrottle: TimeInterval = 10.0 + /// URLs whose final colored badge has already been pushed to Finder. Used + /// to skip the grey-R flash on subsequent folder switches so it only + /// appears once per URL per extension lifetime. + private var coloredURLs: Set = [] + + /// How long to leave the grey "R" on screen before swapping in the real + /// color. Needs to be large enough that Finder's render pipeline actually + /// paints the grey frame; DispatchQueue.main.async alone is too fast and + /// Finder coalesces it with the color update. + private static let greyHoldDuration: TimeInterval = 0.25 + override init() { super.init() os_log("FinderSync init entered", log: gsbLog, type: .default) @@ -70,8 +81,9 @@ class FinderSync: FIFinderSync { override func requestBadgeIdentifier(for url: URL) { let path = url.path + let resolved = url.resolvingSymlinksInPath().path - if let orgFolder = statusReader.orgFolder(forPath: path) { + if let orgFolder = orgFolderLookup(path: path, resolved: resolved) { let finalID = orgFolder.ownerType == .user ? GitSameBadgeConstants.BadgeID.user : GitSameBadgeConstants.BadgeID.org @@ -79,29 +91,70 @@ class FinderSync: FIFinderSync { return } - if let repoStatus = statusReader.repoStatus(forPath: path) { + if let repoStatus = repoLookup(path: path, resolved: resolved) { applyBadge(finalID: badgeID(for: repoStatus.badge), for: url) return } // Unknown path inside a monitored directory: nudge the daemon so the // real color arrives on its next scan instead of waiting up to 30s. - requestRefresh(path: path) + requestRefresh(path: resolved) + } + + /// Look up a repo status under both the raw URL path and the symlink- + /// resolved path. Needed because Finder may present folders reached + /// through volume aliases (e.g. /Volumes/Manuel-SSD-4TB -> /) with the + /// alias prefix, while the daemon writes canonical paths to status.json. + private func repoLookup(path: String, resolved: String) -> FinderRepoStatus? { + if let hit = statusReader.repoStatus(forPath: path) { return hit } + if resolved != path, let hit = statusReader.repoStatus(forPath: resolved) { + return hit + } + return nil } - /// Render grey "R" synchronously so Finder has something to draw right - /// away, then swap in the real color on the next runloop tick. When the - /// final badge is already grey (daemon-marked ambient repo), skip the - /// second call. + private func orgFolderLookup(path: String, resolved: String) -> OrgFolderInfo? { + if let hit = statusReader.orgFolder(forPath: path) { return hit } + if resolved != path, let hit = statusReader.orgFolder(forPath: resolved) { + return hit + } + return nil + } + + /// On first sight of a repo URL, paint grey "R" immediately and schedule + /// the real color after a short hold so Finder actually renders the grey + /// frame. Org and user folders render their own letter (O / U) and skip + /// the placeholder entirely — flashing grey-R before purple-O would be a + /// letter swap, not a loading hint. On subsequent calls for a URL we've + /// already painted, set the final badge directly. private func applyBadge(finalID: String, for url: URL) { let controller = FIFinderSyncController.default() - if finalID == GitSameBadgeConstants.BadgeID.gray { + + let isRBadge = finalID == GitSameBadgeConstants.BadgeID.green + || finalID == GitSameBadgeConstants.BadgeID.blue + || finalID == GitSameBadgeConstants.BadgeID.orange + || finalID == GitSameBadgeConstants.BadgeID.red + + if !isRBadge { + // gray, org, user — set directly, no placeholder. + controller.setBadgeIdentifier(finalID, for: url) + if finalID == GitSameBadgeConstants.BadgeID.gray { + coloredURLs.remove(url) + } else { + coloredURLs.insert(url) + } + return + } + + if coloredURLs.contains(url) { controller.setBadgeIdentifier(finalID, for: url) return } + controller.setBadgeIdentifier(GitSameBadgeConstants.BadgeID.gray, for: url) - DispatchQueue.main.async { + DispatchQueue.main.asyncAfter(deadline: .now() + Self.greyHoldDuration) { [weak self] in controller.setBadgeIdentifier(finalID, for: url) + self?.coloredURLs.insert(url) } } @@ -115,23 +168,38 @@ class FinderSync: FIFinderSync { } } - /// Pre-register badges for every known repo and org-folder URL. Finder's - /// first-paint-per-URL pipeline dominates the visible latency, so setting - /// badges before Finder asks lets the UI render them without a blank gap. + /// Pre-register grey "R" for every known repo and org-folder URL so that + /// Finder has something to paint before it ever calls + /// `requestBadgeIdentifier`. The grey→color flip is driven by + /// `requestBadgeIdentifier` itself (via `applyBadge`), which is the only + /// place guaranteed to happen at actual paint time. Scheduling the color + /// follow-up here instead made the grey flash happen during extension + /// startup (invisible), so this function deliberately avoids that. + /// Already-colored URLs are refreshed to the current color without + /// regressing to grey. private func prewarmBadges() { guard let status = statusReader.currentStatus else { return } + let controller = FIFinderSyncController.default() for orgFolder in status.orgFolders ?? [] { let url = URL(fileURLWithPath: orgFolder.path) let finalID = orgFolder.ownerType == .user ? GitSameBadgeConstants.BadgeID.user : GitSameBadgeConstants.BadgeID.org - applyBadge(finalID: finalID, for: url) + // Org/user badges have their own letter; don't pre-set grey R for + // them, just register the final badge directly. + controller.setBadgeIdentifier(finalID, for: url) + coloredURLs.insert(url) } for repo in status.repos { let url = URL(fileURLWithPath: repo.path) - applyBadge(finalID: badgeID(for: repo.badge), for: url) + let finalID = badgeID(for: repo.badge) + if coloredURLs.contains(url) || finalID == GitSameBadgeConstants.BadgeID.gray { + controller.setBadgeIdentifier(finalID, for: url) + } else { + controller.setBadgeIdentifier(GitSameBadgeConstants.BadgeID.gray, for: url) + } } } @@ -168,10 +236,11 @@ class FinderSync: FIFinderSync { } let path = targetURL.path + let resolved = targetURL.resolvingSymlinksInPath().path let status = statusReader.currentStatus let timestamp = status?.timestamp - if let repoStatus = statusReader.repoStatus(forPath: path) { + if let repoStatus = repoLookup(path: path, resolved: resolved) { let workspaceInfo = repoStatus.workspace.flatMap { name in status?.workspaces.first { $0.name == name } } @@ -183,7 +252,7 @@ class FinderSync: FIFinderSync { ) } - if let orgFolder = statusReader.orgFolder(forPath: path) { + if let orgFolder = orgFolderLookup(path: path, resolved: resolved) { let orgRepos = (status?.repos ?? []).filter { $0.org == orgFolder.org && $0.workspace == orgFolder.workspace } From 663fa90870049811f73034381c288f1c772bd772 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 09:51:20 +0200 Subject: [PATCH 16/89] Split menu into Organization+Workspace and adopt sentence-style Repository view Three changes, all in ContextMenuBuilder.swift: 1. Org-folder menu's Organization submenu splits into Organization (Owner, Type if classified via OwnerType, Path) and Workspace (Name, Root, Orgs in workspace, All orgs with checkmark on the current). Sibling-org listing belongs to the Workspace section because it is workspace-scoped. 2. Repo-folder menu mirrors the split structurally: Organization (just Org name) and Workspace (Name + Root only). The repo menu does NOT include sibling orgs or workspace-org count, since those describe state outside the targeted repo. 3. Repository submenu adopts sentence-style rendering. Bold attributed Status header, then Branch and Upstream (with synced/ahead/behind prose), Default only when it differs, Index (clean or N staged) and Workdir (clean or non-zero parts joined with middle-dot), conditional Stashes and yellow-warning Important ignored files. Identity block (Commits with locale grouping, Path) sits below a separator. The per-repo mini submenu inside the org menu's Repository list reuses the same renderer. Ambient .gray repos collapse the Repository submenu to Status + an italic "Refreshing in background..." hint + Path; the existing on-right-click REFRESH trigger in build(for repo:) is preserved so the daemon upgrades the badge before the user looks again. --- .../GitSameBadgeSync/ContextMenuBuilder.swift | 266 +++++++++++------- 1 file changed, 172 insertions(+), 94 deletions(-) diff --git a/macos/GitSameBadgeSync/ContextMenuBuilder.swift b/macos/GitSameBadgeSync/ContextMenuBuilder.swift index 5340b4d..75f6288 100644 --- a/macos/GitSameBadgeSync/ContextMenuBuilder.swift +++ b/macos/GitSameBadgeSync/ContextMenuBuilder.swift @@ -1,8 +1,9 @@ // ContextMenuBuilder.swift // Builds the right-click context menu for git repository folders and org folders. // Everything lives under a single top-level "Git-Same" item. Inside, data is -// grouped into three sub-submenus: Organization, Repositories, Repository -// details — followed by the last-scan timestamp and the action items. +// grouped into four sub-submenus: Organization, Workspace, Repositories / +// Repository, Repository list / Repository details — followed by the +// last-scan timestamp and the action items. import Cocoa @@ -57,8 +58,9 @@ enum ContextMenuBuilder { let root = NSMenu() root.addItem(submenuRow(title: "Organization", - content: repoOrgSubmenu(repo: repo, - workspaceInfo: workspaceInfo))) + content: repoOrganizationSubmenu(repo: repo))) + root.addItem(submenuRow(title: "Workspace", + content: repoWorkspaceSubmenu(workspaceInfo: workspaceInfo))) root.addItem(submenuRow(title: "Repository", content: repoSelfSubmenu(repo: repo))) root.addItem(submenuRow(title: "Repository details", @@ -83,72 +85,60 @@ enum ContextMenuBuilder { return root } - private static func repoOrgSubmenu(repo: FinderRepoStatus, - workspaceInfo: FinderWorkspaceInfo?) -> NSMenu { + private static func repoOrganizationSubmenu(repo: FinderRepoStatus) -> NSMenu { let sub = NSMenu() - if let workspace = repo.workspace { - sub.addItem(infoItem("Workspace: \(workspace)")) - } if let org = repo.org { sub.addItem(infoItem("Org: \(org)")) + } else { + sub.addItem(infoItem("(no org)")) } + return sub + } + + private static func repoWorkspaceSubmenu(workspaceInfo: FinderWorkspaceInfo?) -> NSMenu { + let sub = NSMenu() if let ws = workspaceInfo { - sub.addItem(infoItem("Workspace root: \(ws.root)")) - sub.addItem(infoItem("Orgs in workspace: \(ws.orgs.count)")) - if !ws.orgs.isEmpty { - let orgsItem = NSMenuItem(title: "All orgs in workspace", - action: nil, keyEquivalent: "") - let orgsSubmenu = NSMenu() - for name in ws.orgs.sorted() { - let marker = (name == repo.org) ? "\u{2713} " : " " - orgsSubmenu.addItem(infoItem("\(marker)\(name)")) - } - orgsItem.submenu = orgsSubmenu - sub.addItem(orgsItem) - } - } - if sub.items.isEmpty { - sub.addItem(infoItem("(no organization context)")) + sub.addItem(infoItem("Name: \(ws.name)")) + sub.addItem(infoItem("Root: \(ws.root)")) + } else { + sub.addItem(infoItem("(no workspace)")) } return sub } private static func repoSelfSubmenu(repo: FinderRepoStatus) -> NSMenu { let sub = NSMenu() - sub.addItem(infoItem("Status: \(badgeMeaning(repo.badge))")) - sub.addItem(infoItem("Path: \(repo.path)")) + + sub.addItem(statusRow(badge: repo.badge)) + + if repo.badge == .gray { + sub.addItem(ambientHintItem()) + sub.addItem(NSMenuItem.separator()) + sub.addItem(infoItem("Path: \(repo.path)")) + return sub + } + + sub.addItem(NSMenuItem.separator()) + sub.addItem(infoItem("Branch: \(repo.currentBranch)")) + sub.addItem(infoItem(upstreamLine(for: repo))) if let defaultBranch = repo.defaultBranch, defaultBranch != repo.currentBranch { sub.addItem(infoItem("Default: \(defaultBranch)")) } - if repo.ahead > 0 || repo.behind > 0 { - sub.addItem(infoItem("Ahead: \(repo.ahead) | Behind: \(repo.behind)")) - } - sub.addItem(infoItem("Commits: \(repo.commitCount)")) - sub.addItem(infoItem( - "Staged: \(repo.stagedCount) | Unstaged: \(repo.unstagedCount)" - )) - if repo.untrackedCount > 0 { - sub.addItem(infoItem("Untracked: \(repo.untrackedCount)")) - } + sub.addItem(infoItem(indexLine(for: repo))) + sub.addItem(infoItem(workdirLine(for: repo))) if repo.stashCount > 0 { sub.addItem(infoItem("Stashes: \(repo.stashCount)")) } if repo.hasImportantIgnoredFiles { - let patterns = repo.importantIgnoredFiles ?? [] - let warnTitle = "\u{26A0} Important ignored files (\(patterns.count))" - let warnItem = NSMenuItem(title: warnTitle, action: nil, keyEquivalent: "") - if !patterns.isEmpty { - let warnSubmenu = NSMenu() - for pattern in patterns { - warnSubmenu.addItem(infoItem(pattern)) - } - warnItem.submenu = warnSubmenu - } else { - warnItem.isEnabled = false - } - sub.addItem(warnItem) + sub.addItem(importantIgnoredItem(repo: repo)) } + + sub.addItem(NSMenuItem.separator()) + + sub.addItem(infoItem("Commits: \(formattedCommits(repo.commitCount))")) + sub.addItem(infoItem("Path: \(repo.path)")) + return sub } @@ -185,8 +175,10 @@ enum ContextMenuBuilder { let root = NSMenu() root.addItem(submenuRow(title: "Organization", - content: orgInfoSubmenu(org: org, - workspaceInfo: workspaceInfo))) + content: orgOrganizationSubmenu(org: org))) + root.addItem(submenuRow(title: "Workspace", + content: workspaceSubmenu(workspaceInfo: workspaceInfo, + currentOrg: org.org))) root.addItem(submenuRow(title: "Repositories (\(repos.count))", content: orgAggregateSubmenu(repos: repos))) root.addItem(submenuRow(title: "Repository list", @@ -206,26 +198,35 @@ enum ContextMenuBuilder { return root } - private static func orgInfoSubmenu(org: OrgFolderInfo, - workspaceInfo: FinderWorkspaceInfo?) -> NSMenu { + private static func orgOrganizationSubmenu(org: OrgFolderInfo) -> NSMenu { let sub = NSMenu() sub.addItem(infoItem("Owner: \(org.org)")) - sub.addItem(infoItem("Workspace: \(org.workspace)")) + if let typeLabel = ownerTypeLabel(org.ownerType) { + sub.addItem(infoItem("Type: \(typeLabel)")) + } sub.addItem(infoItem("Path: \(org.path)")) - if let ws = workspaceInfo { - sub.addItem(infoItem("Workspace root: \(ws.root)")) - sub.addItem(infoItem("Orgs in workspace: \(ws.orgs.count)")) - if !ws.orgs.isEmpty { - let orgsItem = NSMenuItem(title: "All orgs in workspace", - action: nil, keyEquivalent: "") - let orgsSubmenu = NSMenu() - for name in ws.orgs.sorted() { - let marker = (name == org.org) ? "\u{2713} " : " " - orgsSubmenu.addItem(infoItem("\(marker)\(name)")) - } - orgsItem.submenu = orgsSubmenu - sub.addItem(orgsItem) + return sub + } + + private static func workspaceSubmenu(workspaceInfo: FinderWorkspaceInfo?, + currentOrg: String?) -> NSMenu { + let sub = NSMenu() + guard let ws = workspaceInfo else { + sub.addItem(infoItem("(no workspace)")) + return sub + } + sub.addItem(infoItem("Name: \(ws.name)")) + sub.addItem(infoItem("Root: \(ws.root)")) + sub.addItem(infoItem("Orgs in workspace: \(ws.orgs.count)")) + if !ws.orgs.isEmpty { + let orgsItem = NSMenuItem(title: "All orgs", action: nil, keyEquivalent: "") + let orgsSubmenu = NSMenu() + for name in ws.orgs.sorted() { + let marker = (name == currentOrg) ? "\u{2713} " : " " + orgsSubmenu.addItem(infoItem("\(marker)\(name)")) } + orgsItem.submenu = orgsSubmenu + sub.addItem(orgsItem) } return sub } @@ -242,10 +243,11 @@ enum ContextMenuBuilder { sub.addItem(infoItem( "\u{1F7E2} \(counts.green) | \u{1F535} \(counts.blue) | " + "\u{1F7E0} \(counts.orange) | \u{1F534} \(counts.red)" + + " | \u{26AB} \(counts.gray)" )) let totals = aggregate(repos: repos) - sub.addItem(infoItem("Total commits: \(totals.commits)")) + sub.addItem(infoItem("Total commits: \(formattedCommits(totals.commits))")) if totals.staged > 0 || totals.unstaged > 0 || totals.untracked > 0 { sub.addItem(infoItem( "Uncommitted \u{2014} staged: \(totals.staged), " @@ -284,41 +286,99 @@ enum ContextMenuBuilder { for r in repos.sorted(by: { repoBasename($0) < repoBasename($1) }) { let line = "\(badgeEmoji(r.badge)) \(repoBasename(r)) [\(r.currentBranch)]" let row = NSMenuItem(title: line, action: nil, keyEquivalent: "") - row.submenu = repoMiniSubmenu(for: r) + row.submenu = repoSelfSubmenu(repo: r) sub.addItem(row) } return sub } - // MARK: - Per-repo mini submenu (used inside the org repo list) + // MARK: - Repository submenu helpers - private static func repoMiniSubmenu(for repo: FinderRepoStatus) -> NSMenu { - let sub = NSMenu() - sub.addItem(infoItem("Status: \(badgeMeaning(repo.badge))")) - sub.addItem(infoItem("Branch: \(repo.currentBranch)")) - if let def = repo.defaultBranch, def != repo.currentBranch { - sub.addItem(infoItem("Default: \(def)")) - } - sub.addItem(infoItem("Commits: \(repo.commitCount)")) - if repo.ahead > 0 || repo.behind > 0 { - sub.addItem(infoItem("Ahead: \(repo.ahead) | Behind: \(repo.behind)")) + private static func statusRow(badge: Badge) -> NSMenuItem { + let title = "\(badgeEmoji(badge)) \(badgeMeaning(badge))" + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + ] + item.attributedTitle = NSAttributedString(string: title, attributes: attrs) + return item + } + + private static func ambientHintItem() -> NSMenuItem { + let title = "Refreshing in background\u{2026}" + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + let base = NSFont.systemFont(ofSize: NSFont.systemFontSize) + let italic = NSFont(descriptor: base.fontDescriptor.withSymbolicTraits(.italic), + size: NSFont.systemFontSize) ?? base + let attrs: [NSAttributedString.Key: Any] = [ + .font: italic, + .foregroundColor: NSColor.secondaryLabelColor + ] + item.attributedTitle = NSAttributedString(string: title, attributes: attrs) + return item + } + + private static func upstreamLine(for repo: FinderRepoStatus) -> String { + let currentInfo = repo.branches.first { $0.name == repo.currentBranch } + guard let upstream = currentInfo?.upstream else { + return "Upstream: none" + } + let state: String + if repo.ahead > 0 && repo.behind > 0 { + state = "\(repo.ahead) ahead, \(repo.behind) behind" + } else if repo.ahead > 0 { + state = "\(repo.ahead) ahead" + } else if repo.behind > 0 { + state = "\(repo.behind) behind" + } else { + state = "synced" + } + return "Upstream: \(upstream) (\(state))" + } + + private static func indexLine(for repo: FinderRepoStatus) -> String { + if repo.stagedCount == 0 { + return "Index: clean" } - if repo.stagedCount > 0 || repo.unstagedCount > 0 || repo.untrackedCount > 0 { - sub.addItem(infoItem( - "Staged: \(repo.stagedCount) | Unstaged: \(repo.unstagedCount)" - + " | Untracked: \(repo.untrackedCount)" - )) + return "Index: \(repo.stagedCount) staged" + } + + private static func workdirLine(for repo: FinderRepoStatus) -> String { + if repo.unstagedCount == 0 && repo.untrackedCount == 0 { + return "Workdir: clean" } - if repo.stashCount > 0 { - sub.addItem(infoItem("Stashes: \(repo.stashCount)")) + var parts: [String] = [] + if repo.unstagedCount > 0 { parts.append("\(repo.unstagedCount) unstaged") } + if repo.untrackedCount > 0 { parts.append("\(repo.untrackedCount) untracked") } + return "Workdir: " + parts.joined(separator: " \u{00B7} ") + } + + private static func importantIgnoredItem(repo: FinderRepoStatus) -> NSMenuItem { + let patterns = repo.importantIgnoredFiles ?? [] + let title = "\u{26A0} Important ignored files (\(patterns.count))" + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + + let attributed = NSMutableAttributedString(string: title) + let warnRange = (title as NSString).range(of: "\u{26A0}") + if warnRange.location != NSNotFound { + attributed.addAttribute(.foregroundColor, + value: NSColor.systemYellow, + range: warnRange) } - if repo.hasImportantIgnoredFiles { - sub.addItem(infoItem( - "\u{26A0} Important ignored files: \((repo.importantIgnoredFiles ?? []).count)" - )) + item.attributedTitle = attributed + + if !patterns.isEmpty { + let sub = NSMenu() + for p in patterns { + sub.addItem(infoItem(p)) + } + item.submenu = sub + } else { + item.isEnabled = false } - sub.addItem(infoItem("Path: \(repo.path)")) - return sub + return item } // MARK: - Branches / Remotes / Worktrees rows @@ -412,6 +472,14 @@ enum ContextMenuBuilder { } } + private static func ownerTypeLabel(_ ownerType: OwnerType?) -> String? { + switch ownerType { + case .some(.user): return "User" + case .some(.organization): return "Organization" + case .some(.unknown), .none: return nil + } + } + private struct BadgeCounts { var green = 0 var blue = 0 @@ -462,6 +530,16 @@ enum ContextMenuBuilder { return (repo.path as NSString).lastPathComponent } + private static let commitsFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + return f + }() + + private static func formattedCommits(_ count: UInt64) -> String { + return commitsFormatter.string(from: NSNumber(value: count)) ?? "\(count)" + } + private static let isoFormatter: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] From 4445f0fb9968a0fbc385129e51485138d40cdb62 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 10:09:00 +0200 Subject: [PATCH 17/89] Prefill Finder badge cache from status.json, drop grey to color timer The previous design flashed grey for 250ms via DispatchQueue.main.asyncAfter and tracked a coloredURLs set to avoid repeat flashes. In practice this raced Finder's paint tick: sometimes the grey frame was visible, sometimes Finder only painted the color that landed after the timer, and the in-process set did not survive extension suspend/wake. Redefine grey as "we do not yet have a status for this URL" and let Finder's own badge cache do the work. prefillBadges iterates status.repos and status.orgFolders and writes each URL's final badge via setBadgeIdentifier; it runs on init, on every status.json reload, and whenever updateMonitoredDirectories changes directoryURLs. Duplicate writes are no-ops per Apple's docs so repeat calls are free. requestBadgeIdentifier sets the final badge for known URLs and writes grey plus a throttled REFRESH only on a genuine cache miss under a monitored root. Removes coloredURLs, greyHoldDuration, and the applyBadge helper; the timer and its race go with them. --- macos/GitSameBadgeSync/FinderSync.swift | 94 ++++++------------------- 1 file changed, 21 insertions(+), 73 deletions(-) diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index fc7efa1..676f40a 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -15,17 +15,6 @@ class FinderSync: FIFinderSync { private var lastRefreshRequest: [String: Date] = [:] private static let refreshThrottle: TimeInterval = 10.0 - /// URLs whose final colored badge has already been pushed to Finder. Used - /// to skip the grey-R flash on subsequent folder switches so it only - /// appears once per URL per extension lifetime. - private var coloredURLs: Set = [] - - /// How long to leave the grey "R" on screen before swapping in the real - /// color. Needs to be large enough that Finder's render pipeline actually - /// paints the grey frame; DispatchQueue.main.async alone is too fast and - /// Finder coalesces it with the color update. - private static let greyHoldDuration: TimeInterval = 0.25 - override init() { super.init() os_log("FinderSync init entered", log: gsbLog, type: .default) @@ -34,7 +23,7 @@ class FinderSync: FIFinderSync { statusReader.onStatusUpdate = { [weak self] in self?.updateMonitoredDirectories() - self?.prewarmBadges() + self?.prefillBadges() } statusReader.startWatching() @@ -43,7 +32,7 @@ class FinderSync: FIFinderSync { // directoryURLs stays empty until the daemon next rewrites the file, // so Finder never asks us for badges. updateMonitoredDirectories() - prewarmBadges() + prefillBadges() } // MARK: - Monitored Directories @@ -75,6 +64,11 @@ class FinderSync: FIFinderSync { let joined = urls.map { $0.path }.joined(separator: ",") os_log("setDirectoryURLs count=%d paths=%{public}@", log: gsbLog, type: .default, urls.count, joined) + + // Seed Finder's badge cache now that directoryURLs includes these + // roots — any URL in status.json under them will get its real badge + // before Finder's first paint request. + prefillBadges() } // MARK: - Badge Identifiers @@ -82,22 +76,25 @@ class FinderSync: FIFinderSync { override func requestBadgeIdentifier(for url: URL) { let path = url.path let resolved = url.resolvingSymlinksInPath().path + let controller = FIFinderSyncController.default() if let orgFolder = orgFolderLookup(path: path, resolved: resolved) { let finalID = orgFolder.ownerType == .user ? GitSameBadgeConstants.BadgeID.user : GitSameBadgeConstants.BadgeID.org - applyBadge(finalID: finalID, for: url) + controller.setBadgeIdentifier(finalID, for: url) return } if let repoStatus = repoLookup(path: path, resolved: resolved) { - applyBadge(finalID: badgeID(for: repoStatus.badge), for: url) + controller.setBadgeIdentifier(badgeID(for: repoStatus.badge), for: url) return } - // Unknown path inside a monitored directory: nudge the daemon so the - // real color arrives on its next scan instead of waiting up to 30s. + // Unknown path under a monitored root: show grey while we ask the + // daemon to scan it. The reload → prefill path flips it to the real + // color once status.json catches up. + controller.setBadgeIdentifier(GitSameBadgeConstants.BadgeID.gray, for: url) requestRefresh(path: resolved) } @@ -121,43 +118,6 @@ class FinderSync: FIFinderSync { return nil } - /// On first sight of a repo URL, paint grey "R" immediately and schedule - /// the real color after a short hold so Finder actually renders the grey - /// frame. Org and user folders render their own letter (O / U) and skip - /// the placeholder entirely — flashing grey-R before purple-O would be a - /// letter swap, not a loading hint. On subsequent calls for a URL we've - /// already painted, set the final badge directly. - private func applyBadge(finalID: String, for url: URL) { - let controller = FIFinderSyncController.default() - - let isRBadge = finalID == GitSameBadgeConstants.BadgeID.green - || finalID == GitSameBadgeConstants.BadgeID.blue - || finalID == GitSameBadgeConstants.BadgeID.orange - || finalID == GitSameBadgeConstants.BadgeID.red - - if !isRBadge { - // gray, org, user — set directly, no placeholder. - controller.setBadgeIdentifier(finalID, for: url) - if finalID == GitSameBadgeConstants.BadgeID.gray { - coloredURLs.remove(url) - } else { - coloredURLs.insert(url) - } - return - } - - if coloredURLs.contains(url) { - controller.setBadgeIdentifier(finalID, for: url) - return - } - - controller.setBadgeIdentifier(GitSameBadgeConstants.BadgeID.gray, for: url) - DispatchQueue.main.asyncAfter(deadline: .now() + Self.greyHoldDuration) { [weak self] in - controller.setBadgeIdentifier(finalID, for: url) - self?.coloredURLs.insert(url) - } - } - private func badgeID(for badge: Badge) -> String { switch badge { case .green: return GitSameBadgeConstants.BadgeID.green @@ -168,16 +128,12 @@ class FinderSync: FIFinderSync { } } - /// Pre-register grey "R" for every known repo and org-folder URL so that - /// Finder has something to paint before it ever calls - /// `requestBadgeIdentifier`. The grey→color flip is driven by - /// `requestBadgeIdentifier` itself (via `applyBadge`), which is the only - /// place guaranteed to happen at actual paint time. Scheduling the color - /// follow-up here instead made the grey flash happen during extension - /// startup (invisible), so this function deliberately avoids that. - /// Already-colored URLs are refreshed to the current color without - /// regressing to grey. - private func prewarmBadges() { + /// Push the final badge for every known repo and org/user folder into + /// Finder's badge cache. Called on cold start, on every status.json + /// reload, and whenever directoryURLs changes. Idempotent: duplicate + /// writes are free per Apple's docs ("if the identifier matches the badge + /// in use, Finder takes no action"), so we can call this liberally. + private func prefillBadges() { guard let status = statusReader.currentStatus else { return } let controller = FIFinderSyncController.default() @@ -186,20 +142,12 @@ class FinderSync: FIFinderSync { let finalID = orgFolder.ownerType == .user ? GitSameBadgeConstants.BadgeID.user : GitSameBadgeConstants.BadgeID.org - // Org/user badges have their own letter; don't pre-set grey R for - // them, just register the final badge directly. controller.setBadgeIdentifier(finalID, for: url) - coloredURLs.insert(url) } for repo in status.repos { let url = URL(fileURLWithPath: repo.path) - let finalID = badgeID(for: repo.badge) - if coloredURLs.contains(url) || finalID == GitSameBadgeConstants.BadgeID.gray { - controller.setBadgeIdentifier(finalID, for: url) - } else { - controller.setBadgeIdentifier(GitSameBadgeConstants.BadgeID.gray, for: url) - } + controller.setBadgeIdentifier(badgeID(for: repo.badge), for: url) } } From f0247bc9b719c0e26c1e38d70050c0472714f74a Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 14:46:14 +0200 Subject: [PATCH 18/89] Fix grey R badge painted on every folder under monitored root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestBadgeIdentifier painted GitSameBadgeConstants.BadgeID.gray on every cache miss, so every non-repo folder Finder displayed — children inside a repo, unrelated workspace siblings — got a grey "R". The daemon's ambient scan already publishes all real repos to status.repos and prefillBadges paints them, so the optimistic fallback is redundant. Drop the setBadgeIdentifier call on miss and keep only the throttled REFRESH nudge, matching the pre-0399bb9 behavior. Grey is still used for ambient repos via the prefill path. --- macos/GitSameBadgeSync/FinderSync.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadgeSync/FinderSync.swift index 676f40a..d75568e 100644 --- a/macos/GitSameBadgeSync/FinderSync.swift +++ b/macos/GitSameBadgeSync/FinderSync.swift @@ -91,10 +91,9 @@ class FinderSync: FIFinderSync { return } - // Unknown path under a monitored root: show grey while we ask the - // daemon to scan it. The reload → prefill path flips it to the real - // color once status.json catches up. - controller.setBadgeIdentifier(GitSameBadgeConstants.BadgeID.gray, for: url) + // Unknown path under a monitored root: no badge. Nudge the daemon so + // its next ambient scan picks up any new repo here; prefillBadges + // then paints the real (or grey-ambient) badge on reload. requestRefresh(path: resolved) } From 6335f52044ee721c978632d9cc0179b0cfd72c3d Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 20 Apr 2026 17:01:04 +0200 Subject: [PATCH 19/89] Update run.sh cheat-sheet with daemon, scan, and TUI commands Align run.sh help output with the current CLI so developers see every subcommand defined in src/cli.rs. daemon, scan, and the no-subcommand TUI entrypoint were missing. Also fill in flags that were previously absent: sync --refresh, sync --no-skip-uncommitted, status --behind, status --org, and workspace default --clear. Install flow and alias-symlink logic unchanged. --- toolkit/conductor/run.sh | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/toolkit/conductor/run.sh b/toolkit/conductor/run.sh index da80e50..0b302c1 100755 --- a/toolkit/conductor/run.sh +++ b/toolkit/conductor/run.sh @@ -73,6 +73,10 @@ echo "" echo " $GS_COMMAND init # Create config file" echo " $GS_COMMAND setup # Interactive workspace wizard" echo "" +echo "Interactive TUI:" +echo "" +echo " $GS_COMMAND # Launch TUI (no subcommand)" +echo "" echo "Sync repos (discover + clone new + fetch existing):" echo "" echo " $GS_COMMAND sync --dry-run # Preview what would happen" @@ -80,18 +84,38 @@ echo " $GS_COMMAND sync # Run sync (fetch mode)" echo " $GS_COMMAND sync --pull # Sync with pull instead of fetch" echo " $GS_COMMAND sync --workspace github # Sync specific workspace" echo " $GS_COMMAND sync --concurrency 8 # Control parallelism" +echo " $GS_COMMAND sync --refresh # Ignore cache, re-discover repos" +echo " $GS_COMMAND sync --no-skip-uncommitted # Don't skip dirty repos" echo "" echo "Status:" echo "" echo " $GS_COMMAND status # Show all repo status" echo " $GS_COMMAND status --uncommitted # Only repos with changes" +echo " $GS_COMMAND status --behind # Only repos behind upstream" echo " $GS_COMMAND status --detailed # Full detail per repo" +echo " $GS_COMMAND status --org my-org # Filter to one org (repeatable)" echo "" echo "Workspace management:" echo "" echo " $GS_COMMAND workspace list # List configured workspaces" -echo " $GS_COMMAND workspace default my-ws # Set default workspace" echo " $GS_COMMAND workspace default # Show current default" +echo " $GS_COMMAND workspace default my-ws # Set default workspace" +echo " $GS_COMMAND workspace default --clear # Clear the default" +echo "" +echo "Scan for unregistered workspaces:" +echo "" +echo " $GS_COMMAND scan # Scan current directory" +echo " $GS_COMMAND scan ~/projects # Scan a specific directory" +echo " $GS_COMMAND scan --depth 3 # Limit search depth" +echo " $GS_COMMAND scan ~/projects --register # Auto-register found workspaces" +echo "" +echo "Finder extension daemon (macOS):" +echo "" +echo " $GS_COMMAND daemon # Start daemon (daemonizes)" +echo " $GS_COMMAND daemon --foreground # Run in foreground (debug)" +echo " $GS_COMMAND daemon --interval 60 # Poll every 60 seconds" +echo " $GS_COMMAND daemon --status # Check if daemon is running" +echo " $GS_COMMAND daemon --stop # Stop a running daemon" echo "" echo "Reset / cleanup:" echo "" From b7f7b1a3b49999400a96d9393927bad4794835fd Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 22 Apr 2026 18:24:44 +0200 Subject: [PATCH 20/89] Nudge Finder daemon after sync so new clones get their repo badge immediately After cloning a repo, sync now sends REFRESH_ALL to the daemon's Unix socket via the existing UnixSocketClient. Without this, status.json stays stale until the daemon's next poll and Finder paints no "R" badge on freshly cloned folders. The call is best-effort: if the daemon is not running, sync still succeeds and the failure is logged at debug only. --- src/commands/sync_cmd.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/commands/sync_cmd.rs b/src/commands/sync_cmd.rs index 1abc60e..eac32ee 100644 --- a/src/commands/sync_cmd.rs +++ b/src/commands/sync_cmd.rs @@ -169,9 +169,28 @@ pub async fn run(args: &SyncCmdArgs, config: &Config, output: &Output) -> Result output.verbose(&format!("Warning: Failed to update last_synced: {}", e)); } + // Best-effort: nudge the Finder daemon so badges refresh for new clones. + // If the daemon is not running we silently skip; sync still succeeded. + nudge_daemon_refresh().await; + Ok(()) } +#[cfg(unix)] +async fn nudge_daemon_refresh() { + use crate::ipc::{IpcConfig, UnixSocketClient}; + let Ok(cfg) = IpcConfig::default_path() else { + return; + }; + let client = UnixSocketClient::new(cfg.socket_path()); + if let Err(e) = client.refresh_all().await { + tracing::debug!(error = %e, "Daemon refresh nudge skipped"); + } +} + +#[cfg(not(unix))] +async fn nudge_daemon_refresh() {} + #[cfg(test)] #[path = "sync_cmd_tests.rs"] mod tests; From b6c1e5508829395f99958dabcbfed717618fb28f Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 22 Apr 2026 18:28:53 +0200 Subject: [PATCH 21/89] Seed StatusReader on first successful open so badges show without a second daemon write startWatching() opens status.json with O_EVTONLY and, when the file is missing, reschedules itself every 5 s. On the retry that finally succeeds we armed the DispatchSource but never called reload() or onStatusUpdate, so the extension waited for the daemon's next write (up to the polling interval, default 30 s) before any badges could appear. If the daemon had just finished its initial scan and sat idle, badges could stay blank for a full scan cycle. Call reload() plus dispatch onStatusUpdate on the main queue right after source.resume(). Both are idempotent so the common hot path (file present at init) is unaffected. --- macos/Shared/StatusReader.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/macos/Shared/StatusReader.swift b/macos/Shared/StatusReader.swift index 85fc40f..4255557 100644 --- a/macos/Shared/StatusReader.swift +++ b/macos/Shared/StatusReader.swift @@ -62,6 +62,16 @@ class StatusReader { fileMonitor = source source.resume() + + // The DispatchSource only fires on subsequent writes. If the file + // already exists when we first successfully open it (e.g. the + // extension started before the daemon and this is a retry that + // finally caught the file), seed currentStatus now so observers see + // the status without waiting for the next daemon write. + reload() + DispatchQueue.main.async { [weak self] in + self?.onStatusUpdate?() + } } /// Stop watching the status file. From 515b243ee95bc9d7c661d3b780005f8c8305612c Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 22 Apr 2026 19:21:05 +0200 Subject: [PATCH 22/89] Nudge daemon after reset and add gisa refresh for manual badge resync Reset now sends REFRESH_ALL to the daemon on both success paths (--force and interactive) so Finder badges drop as soon as workspace configs are wiped, matching the post-sync nudge in 6ae60ff. A new top-level `gisa refresh` subcommand (with optional --path) gives users a direct way to force a status.json rewrite when debugging badges or after manual on-disk edits, with a clear error when the daemon is not running. Cheat-sheet and three plan docs under docs/plans/ round out the change. --- docs/plans/dependabot-cleanup.md | 36 ++++++++++++++++++++ docs/plans/refresh-subcommand.md | 56 ++++++++++++++++++++++++++++++++ docs/plans/reset-daemon-nudge.md | 32 ++++++++++++++++++ src/cli.rs | 21 ++++++++++++ src/commands/mod.rs | 2 ++ src/commands/refresh.rs | 49 ++++++++++++++++++++++++++++ src/commands/refresh_tests.rs | 23 +++++++++++++ src/commands/reset.rs | 17 ++++++++++ toolkit/conductor/run.sh | 2 ++ 9 files changed, 238 insertions(+) create mode 100644 docs/plans/dependabot-cleanup.md create mode 100644 docs/plans/refresh-subcommand.md create mode 100644 docs/plans/reset-daemon-nudge.md create mode 100644 src/commands/refresh.rs create mode 100644 src/commands/refresh_tests.rs diff --git a/docs/plans/dependabot-cleanup.md b/docs/plans/dependabot-cleanup.md new file mode 100644 index 0000000..694525b --- /dev/null +++ b/docs/plans/dependabot-cleanup.md @@ -0,0 +1,36 @@ +# Plan — Clear Dependabot alerts (3 low-severity) + +## Context + +Pushing `6ae60ff` surfaced `GitHub found 3 vulnerabilities on ZAAI-com/git-same's default branch (3 low)`. Raw data from `gh api repos/ZAAI-com/git-same/dependabot/alerts`: + +| # | Package | Vulnerable range | Patched | Severity | +|---|---|---|---|---| +| 9 | `rand` | `>= 0.7.0, < 0.9.3` | `0.9.3` | low | +| 10 | `rustls-webpki` | `>= 0.101.0, < 0.103.12` | `0.103.12` | low | +| 11 | `rustls-webpki` | `>= 0.101.0, < 0.103.12` | `0.103.12` | low | + +All three are in `Cargo.lock` only (no source change required). On the `C/Finder-Icons` branch, `Cargo.lock` already shows `rustls-webpki 0.103.12` and `rand 0.9.4` as the primary versions — those alerts may already be resolved on the default branch as of the most recent `Update Cargo.lock` commit (`d576e63`), and GitHub just hasn't re-scanned. However, `Cargo.lock` also still contains a stale `rand 0.8.6` entry (lines 1879-1886) that `cargo tree` cannot trace back to any consumer — a likely cruft entry that a fresh `cargo update` should prune. + +## Steps + +1. On `main` (NOT a Finder branch), run `cargo update` to let Cargo recompute the lockfile. Confirm `rand 0.8.6` drops out. If it doesn't, `cargo tree --target all --all-features -i -p rand@0.8.6` + `grep` Cargo.lock backward to find the consumer — may need a targeted `cargo update -p rand@0.8.6` or a Cargo.toml bump for the intermediate crate. +2. Run `cargo audit` locally (already part of `S1-Test-CI.yml`). Should report zero advisories after step 1. +3. `cargo test && cargo clippy -- -D warnings && cargo fmt -- --check`. +4. Commit as `Bump Cargo.lock to drop vulnerable rand and rustls-webpki versions`. Push to `main` via a small PR (do NOT piggyback on another feature branch — these are independent changes). +5. Wait for Dependabot to re-scan (usually within minutes after push). Confirm the three open alerts auto-close. If any stay open, manually dismiss with a reason via `gh api -X PATCH repos/ZAAI-com/git-same/dependabot/alerts/ -f state=dismissed -f dismissed_reason=fix_started` (only if truly a false positive). + +## Verification + +- `cargo audit` clean locally. +- GitHub Dependabot dashboard shows 0 open alerts. +- Next `S1-Test-CI` workflow run is green on the audit job. + +## Risk / roll-back + +- `cargo update` can pull in minor-bumped transitive deps with behavior changes. After updating, run the full test suite and do a manual smoke test of `gisa sync` against a small real workspace before merging. Roll back by `git restore Cargo.lock`. + +## Out of scope + +- Upgrading to `rustls-webpki 0.104.x` (still alpha per advisory; don't chase pre-releases). +- Adding `cargo deny` or stricter audit gating. diff --git a/docs/plans/refresh-subcommand.md b/docs/plans/refresh-subcommand.md new file mode 100644 index 0000000..be24bb7 --- /dev/null +++ b/docs/plans/refresh-subcommand.md @@ -0,0 +1,56 @@ +# Plan — New `gisa refresh` subcommand + +## Context + +Users occasionally want to force an immediate `status.json` rewrite without running a full sync (e.g. after manually deleting a repo, or when debugging badge issues). The socket protocol already supports this via `REFRESH_ALL` and `REFRESH /path`; we just need a user-facing surface. It's also a natural place to diagnose "is the daemon running?" because the command either succeeds (daemon alive) or prints a helpful error (daemon down, start with `gisa daemon`). + +## Files to create + +- `src/commands/refresh.rs` — new handler, mirroring `src/commands/status.rs:11` shape. +- `src/commands/refresh_tests.rs` — colocated tests per CLAUDE.md convention. + +## Files to modify + +- `src/cli.rs` — add a `Refresh(RefreshArgs)` variant to the `Command` enum (around line 124 where `Status` is defined), plus a `RefreshArgs` struct. Likely flags: + - `--path `: optional single-path refresh (routes to `REFRESH /path` instead of `REFRESH_ALL`). + - No others for the MVP. +- `src/commands/mod.rs:29-68` — add `Command::Refresh(args) => refresh::run(args, &config, output).await` arm in the match block (around line 65 where `Status` is routed). Also declare `pub mod refresh;` near the other modules. +- `run.sh` — append a line to the cheat sheet, consistent with commit `33d0c7a` that added daemon/scan/TUI commands. + +## Handler shape + +```rust +pub async fn run(args: &RefreshArgs, _config: &Config, output: &Output) -> Result<()> { + use crate::ipc::{IpcConfig, UnixSocketClient}; + let cfg = IpcConfig::default_path()?; + let client = UnixSocketClient::new(cfg.socket_path()); + let response = match args.path.as_deref() { + Some(p) => client.refresh(p).await, + None => client.refresh_all().await, + }; + match response { + Ok(_) => { output.success("Daemon refreshed"); Ok(()) } + Err(e) => { output.error("Daemon not reachable. Start it with `gisa daemon`."); Err(e) } + } +} +``` + +Note: unlike the post-sync/post-reset nudges, `gisa refresh` is user-initiated, so a daemon-down state SHOULD return a clear error (not silent). That is the one meaningful behavior difference. + +## Windows / non-unix + +`UnixSocketClient` is `#[cfg(unix)]`. Gate the handler similarly; on non-unix print a short "refresh is unix-only for now" message and return `Ok(())`. `src/ipc/mod.rs:1-8` notes Windows named-pipe support is planned but not shipped. + +## Verification + +1. `gisa daemon` in a terminal. +2. `gisa refresh` → "Daemon refreshed" printed; `status.json` mtime bumps. +3. `gisa refresh --path /path/to/org` → same, targeted. +4. Kill daemon, `gisa refresh` → clear error, non-zero exit. +5. `cargo test` includes new `refresh_tests.rs`. +6. Manual TUI regression pass: no screen references `refresh` yet, so no TUI changes needed. + +## Out of scope + +- Auto-starting the daemon if it's down (needs a separate decision about launchd/systemd integration). +- A `--watch` mode. diff --git a/docs/plans/reset-daemon-nudge.md b/docs/plans/reset-daemon-nudge.md new file mode 100644 index 0000000..687e7cb --- /dev/null +++ b/docs/plans/reset-daemon-nudge.md @@ -0,0 +1,32 @@ +# Plan — Nudge the daemon after `gisa reset` + +## Context + +`gisa reset` removes git-same config, workspace metadata, and cached discovery data (cloned repos stay on disk). Per `src/cli.rs:142-145` help text, this does not delete repos, but it does invalidate everything the daemon currently believes about workspaces. Without a nudge, the daemon keeps serving stale `status.json` until its next poll, so Finder keeps painting "R" badges for workspaces the user just wiped. + +Same root-cause shape as the sync case (commit `6ae60ff`): state changes on disk, daemon doesn't know. + +## Files to modify + +- `src/commands/reset.rs:49` — `pub async fn run(args: &ResetArgs, output: &Output) -> Result<()>`. Add the nudge immediately before the final `Ok(())` at line 74, after `execute_reset()` returns successfully. + +## Reuse + +- Same helper shape already added to `sync_cmd.rs` in commit `6ae60ff` (`nudge_daemon_refresh` private async fn wrapping `UnixSocketClient::refresh_all`). Either duplicate the 12 lines into `reset.rs` or lift it to `src/commands/mod.rs` as a `pub(super)` helper. Duplicating is fine for now — two callers isn't premature abstraction. + +## Gating + +- Fire the nudge only on the real-work path. Reset has no `--dry-run` flag today, only `--force` (skip confirmation). Fire after `execute_reset()` succeeds regardless of `--force`. + +## Verification + +1. Start `gisa daemon` in the background and populate `status.json` via `gisa sync`. +2. Run `gisa reset --force`. +3. In Finder, the previously-badged workspace folders should lose their badges within a second (daemon re-scans and writes an updated `status.json`). +4. Kill the daemon, repeat — reset must still succeed silently. +5. `cargo test && cargo clippy -- -D warnings && cargo fmt -- --check`. + +## Out of scope + +- Any change to what `reset` actually deletes. +- Adding a `--dry-run` flag. diff --git a/src/cli.rs b/src/cli.rs index f197042..107b3a3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -182,6 +182,19 @@ Examples: gisa scan ~/projects --register Auto-register found workspaces" )] Scan(ScanArgs), + + /// Ask the running daemon to refresh status.json immediately + #[command( + long_about = "Send a refresh request to the background daemon so it \ + rewrites ~/.config/git-same/finder/status.json right now. Useful \ + after manually deleting a repo, or when debugging Finder badges. \ + Fails with a clear error if the daemon is not running.", + after_help = "\ +Examples: + gisa refresh Refresh everything the daemon knows about + gisa refresh --path ~/work/org Refresh a single folder" + )] + Refresh(RefreshArgs), } /// Arguments for the init command @@ -327,6 +340,14 @@ pub struct DaemonArgs { pub status: bool, } +/// Arguments for the refresh command +#[derive(Args, Debug)] +pub struct RefreshArgs { + /// Refresh a specific folder instead of everything the daemon monitors + #[arg(long)] + pub path: Option, +} + /// Arguments for the scan command #[derive(Args, Debug)] pub struct ScanArgs { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4d8a0e7..c946ad5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod daemon; pub mod init; +pub mod refresh; pub mod reset; pub mod scan; #[cfg(feature = "tui")] @@ -64,6 +65,7 @@ pub async fn run_command( Command::Sync(args) => run_sync_cmd(args, &config, output).await, Command::Status(args) => run_status(args, &config, output).await, Command::Workspace(args) => workspace::run(args, &config, output), + Command::Refresh(args) => refresh::run(args, &config, output).await, } } diff --git a/src/commands/refresh.rs b/src/commands/refresh.rs new file mode 100644 index 0000000..2b11cf0 --- /dev/null +++ b/src/commands/refresh.rs @@ -0,0 +1,49 @@ +//! Refresh command handler. +//! +//! User-facing wrapper around the daemon's REFRESH / REFRESH_ALL socket commands. +//! Forces an immediate status.json rewrite so the Finder extension picks up +//! on-disk changes without waiting for the daemon's next poll. + +use crate::cli::RefreshArgs; +use crate::config::Config; +use crate::errors::Result; +use crate::output::Output; + +/// Ask the running daemon to refresh its status cache. +pub async fn run(args: &RefreshArgs, _config: &Config, output: &Output) -> Result<()> { + run_impl(args, output).await +} + +#[cfg(unix)] +async fn run_impl(args: &RefreshArgs, output: &Output) -> Result<()> { + use crate::ipc::{IpcConfig, UnixSocketClient}; + + let cfg = IpcConfig::default_path()?; + let client = UnixSocketClient::new(cfg.socket_path()); + + let response = match args.path.as_deref() { + Some(p) => client.refresh(p).await, + None => client.refresh_all().await, + }; + + match response { + Ok(_) => { + output.success("Daemon refreshed"); + Ok(()) + } + Err(e) => { + output.error("Daemon not reachable. Start it with `gisa daemon`."); + Err(e) + } + } +} + +#[cfg(not(unix))] +async fn run_impl(_args: &RefreshArgs, output: &Output) -> Result<()> { + output.warn("`gisa refresh` is unix-only for now (no daemon socket on this platform)."); + Ok(()) +} + +#[cfg(test)] +#[path = "refresh_tests.rs"] +mod tests; diff --git a/src/commands/refresh_tests.rs b/src/commands/refresh_tests.rs new file mode 100644 index 0000000..0ebb4ef --- /dev/null +++ b/src/commands/refresh_tests.rs @@ -0,0 +1,23 @@ +use super::*; +use crate::cli::RefreshArgs; +use crate::output::{Output, Verbosity}; + +#[tokio::test] +async fn refresh_with_no_daemon_returns_error_on_unix() { + // With no daemon listening on the socket, the command must surface an + // error (unlike the post-sync/post-reset nudges, which stay silent). + let args = RefreshArgs { path: None }; + let cfg = Config::default(); + let output = Output::new(Verbosity::Quiet, false); + + #[cfg(unix)] + { + let res = run(&args, &cfg, &output).await; + assert!(res.is_err(), "expected error when daemon is not running"); + } + #[cfg(not(unix))] + { + let res = run(&args, &cfg, &output).await; + assert!(res.is_ok(), "non-unix fallback should succeed"); + } +} diff --git a/src/commands/reset.rs b/src/commands/reset.rs index b478d9b..efdce01 100644 --- a/src/commands/reset.rs +++ b/src/commands/reset.rs @@ -58,6 +58,7 @@ pub async fn run(args: &ResetArgs, output: &Output) -> Result<()> { if args.force { display_detailed_targets(&ResetScope::Everything, &target, output); execute_reset(&ResetScope::Everything, &target, output)?; + nudge_daemon_refresh().await; return Ok(()); } @@ -71,9 +72,25 @@ pub async fn run(args: &ResetArgs, output: &Output) -> Result<()> { } execute_reset(&scope, &target, output)?; + nudge_daemon_refresh().await; Ok(()) } +#[cfg(unix)] +async fn nudge_daemon_refresh() { + use crate::ipc::{IpcConfig, UnixSocketClient}; + let Ok(cfg) = IpcConfig::default_path() else { + return; + }; + let client = UnixSocketClient::new(cfg.socket_path()); + if let Err(e) = client.refresh_all().await { + tracing::debug!(error = %e, "Daemon refresh nudge skipped"); + } +} + +#[cfg(not(unix))] +async fn nudge_daemon_refresh() {} + /// Discover what files and directories exist that could be removed. fn discover_targets() -> Result { let config_path = Config::default_path()?; diff --git a/toolkit/conductor/run.sh b/toolkit/conductor/run.sh index 0b302c1..1334b44 100755 --- a/toolkit/conductor/run.sh +++ b/toolkit/conductor/run.sh @@ -116,6 +116,8 @@ echo " $GS_COMMAND daemon --foreground # Run in foreground (debug echo " $GS_COMMAND daemon --interval 60 # Poll every 60 seconds" echo " $GS_COMMAND daemon --status # Check if daemon is running" echo " $GS_COMMAND daemon --stop # Stop a running daemon" +echo " $GS_COMMAND refresh # Force immediate status.json rewrite" +echo " $GS_COMMAND refresh --path ~/work/org # Refresh a single folder" echo "" echo "Reset / cleanup:" echo "" From cbb5398dc99f84a8aa8ea1e9c5d29c024d3e14b1 Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 6 May 2026 18:10:24 +0200 Subject: [PATCH 23/89] Add nonconcurrent Conductor run mode to prevent install races --- conductor.json | 1 + 1 file changed, 1 insertion(+) diff --git a/conductor.json b/conductor.json index 6fd997f..f1fa5fd 100644 --- a/conductor.json +++ b/conductor.json @@ -6,6 +6,7 @@ "run": "./toolkit/conductor/run.sh", "archive": "./toolkit/conductor/archive.sh" }, + "runScriptMode": "nonconcurrent", "stack": { "language": "Rust", "cli": "Clap v4", From 7fb0c82035d8a9571549ee3d7b95d69578e46bcc Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 11:58:04 +0200 Subject: [PATCH 24/89] Bump version to 3.1.0 for next release --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4550ce4..f8e7877 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -864,7 +864,7 @@ dependencies = [ [[package]] name = "git-same" -version = "3.0.2" +version = "3.1.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index d54e215..34031f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-same" -version = "3.0.2" +version = "3.1.0" edition = "2021" authors = ["Git-Same Contributors"] description = "Mirror GitHub structure /orgs/repos/ to local file system." From e33fc5da384cfb7bf329248775d9c3a377551973 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 18:17:06 +0200 Subject: [PATCH 25/89] Surface unreadable repos in status and stop calling [gone] branches synced Two regressions surfaced by the C/Finder-Badges review: 1. The status command's refactor to RepoScanService dropped per-repo error tracking. scan_repo's unwrap_or_else / unwrap_or_default chain coerced any git failure into all-zero defaults, which compute_badge then resolved to Badge::Green, so a corrupted or partially cloned repo printed as "(clean)" and rolled into "All repositories are clean and up to date". scan_repo now captures the error in a new read_error field and forces Badge::Gray; the CLI counts those repos separately and warns "N repositories could not be checked". 2. for-each-ref reports %(upstream:track) as "[gone]" when a local branch's upstream ref has been deleted. parse_track_info only matched ahead/behind, so [gone] yielded (0, 0) and is_synced evaluated to true (the upstream:short field still returned the dead ref name). That could promote a repo to Badge::Green and tell the user it was safe to delete a branch whose commits no longer existed on any remote. list_branches now clears the upstream when track is [gone] so is_synced and all_branches_synced are correct. Tests cover both paths, including a new MockGit hook for failing status calls per path. --- src/api/ambient_upgrade_cache_tests.rs | 1 + src/api/service.rs | 59 ++++++++++++--------- src/api/service_tests.rs | 29 +++++++++++ src/commands/status.rs | 41 ++++++++++----- src/git/shell.rs | 71 +++++++++++++++----------- src/git/shell_tests.rs | 59 +++++++++++++++++++++ src/git/traits.rs | 15 ++++++ src/ipc/status_file_tests.rs | 1 + src/types/finder_status.rs | 6 +++ src/types/finder_status_tests.rs | 1 + 10 files changed, 217 insertions(+), 66 deletions(-) diff --git a/src/api/ambient_upgrade_cache_tests.rs b/src/api/ambient_upgrade_cache_tests.rs index 5197b75..ac77381 100644 --- a/src/api/ambient_upgrade_cache_tests.rs +++ b/src/api/ambient_upgrade_cache_tests.rs @@ -24,6 +24,7 @@ fn sample_status(path: &str, badge: Badge) -> FinderRepoStatus { remotes: Vec::new(), worktrees: Vec::new(), all_worktrees_synced: true, + read_error: None, } } diff --git a/src/api/service.rs b/src/api/service.rs index 75db97d..a807a56 100644 --- a/src/api/service.rs +++ b/src/api/service.rs @@ -265,6 +265,7 @@ impl<'a> RepoScanService<'a> { remotes: Vec::new(), worktrees: Vec::new(), all_worktrees_synced: true, + read_error: None, } } @@ -314,19 +315,25 @@ impl<'a> RepoScanService<'a> { ) -> FinderRepoStatus { let git = self.git; - // Get basic status - let repo_status = git - .status(repo_path) - .unwrap_or_else(|_| crate::git::RepoStatus { - branch: "unknown".to_string(), - is_uncommitted: false, - ahead: 0, - behind: 0, - has_untracked: false, - staged_count: 0, - unstaged_count: 0, - untracked_count: 0, - }); + // Get basic status. If `git status` fails, capture the error so the + // CLI can warn the user and we can force the badge to Gray instead + // of letting the all-zero defaults masquerade as a clean repo. + let (repo_status, read_error) = match git.status(repo_path) { + Ok(s) => (s, None), + Err(e) => ( + crate::git::RepoStatus { + branch: "unknown".to_string(), + is_uncommitted: false, + ahead: 0, + behind: 0, + has_untracked: false, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + }, + Some(e.to_string()), + ), + }; // Get branches let branches: Vec = git @@ -403,16 +410,21 @@ impl<'a> RepoScanService<'a> { (false, Vec::new()) }; - // Compute badge - let badge = compute_badge( - repo_status.staged_count, - repo_status.unstaged_count, - repo_status.untracked_count, - repo_status.ahead, - all_branches_synced, - all_worktrees_synced, - has_important_ignored_files, - ); + // Compute badge. Unreadable repos stay Gray so they don't pose as + // healthy Green repos in the Finder or in `gisa status`. + let badge = if read_error.is_some() { + Badge::Gray + } else { + compute_badge( + repo_status.staged_count, + repo_status.unstaged_count, + repo_status.untracked_count, + repo_status.ahead, + all_branches_synced, + all_worktrees_synced, + has_important_ignored_files, + ) + }; FinderRepoStatus { path: repo_path.to_path_buf(), @@ -435,6 +447,7 @@ impl<'a> RepoScanService<'a> { remotes, worktrees, all_worktrees_synced, + read_error, } } diff --git a/src/api/service_tests.rs b/src/api/service_tests.rs index 89457db..7832129 100644 --- a/src/api/service_tests.rs +++ b/src/api/service_tests.rs @@ -59,6 +59,34 @@ fn test_scan_repo_dirty() { assert_eq!(status.ahead, 2); } +#[test] +fn scan_repo_captures_read_error_and_forces_gray_badge() { + // A repo whose `git status` fails must be flagged via `read_error` so the + // CLI can warn the user. Crucially the badge must NOT be Green; that is + // the original regression that caused unreadable repos to roll into + // "All repositories are clean and up to date". + let mock_cfg = MockConfig { + fail_status_paths: vec!["/tmp/broken-repo".to_string()], + error_message: Some("fatal: not a git repository".to_string()), + ..Default::default() + }; + let mock = MockGit::with_config(mock_cfg); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_repo(Path::new("/tmp/broken-repo"), Some("ws"), Some("org")); + + assert!( + status.read_error.is_some(), + "scan_repo should surface git.status errors via read_error" + ); + assert_eq!( + status.badge, + Badge::Gray, + "unreadable repos must not show as Badge::Green; that was the original regression" + ); +} + #[test] fn test_scan_repo_no_workspace() { let mock = MockGit::new(); @@ -184,6 +212,7 @@ fn ambient_upgrade_cache_preserves_semantic_color() { remotes: Vec::new(), worktrees: Vec::new(), all_worktrees_synced: true, + read_error: None, }; upgrades.set(repo_path.clone(), upgraded); diff --git a/src/commands/status.rs b/src/commands/status.rs index 641e867..6bbcbad 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -29,19 +29,11 @@ pub async fn run(args: &StatusArgs, config: &Config, output: &Output) -> Result< // Get status for each let mut uncommitted_count = 0; let mut behind_count = 0; + let mut error_count = 0; for repo in &repos { - let is_uncommitted = - repo.staged_count > 0 || repo.unstaged_count > 0 || repo.untracked_count > 0; - let is_behind = repo.behind > 0; - - // Apply filters - if args.uncommitted && !is_uncommitted { - continue; - } - if args.behind && !is_behind { - continue; - } + // Apply org filter first so it suppresses output for both readable + // and unreadable repos consistently. if !args.org.is_empty() { let matches_org = repo .org @@ -53,6 +45,25 @@ pub async fn run(args: &StatusArgs, config: &Config, output: &Output) -> Result< } } + // Repos that couldn't be read get tallied separately; they have + // zero counts and would otherwise appear as clean. + if let Some(err) = &repo.read_error { + error_count += 1; + output.verbose(&format!(" {} - error: {}", repo.path.display(), err)); + continue; + } + + let is_uncommitted = + repo.staged_count > 0 || repo.unstaged_count > 0 || repo.untracked_count > 0; + let is_behind = repo.behind > 0; + + if args.uncommitted && !is_uncommitted { + continue; + } + if args.behind && !is_behind { + continue; + } + if is_uncommitted { uncommitted_count += 1; } @@ -116,7 +127,13 @@ pub async fn run(args: &StatusArgs, config: &Config, output: &Output) -> Result< "{} repositories are behind upstream", behind_count )); - } else if uncommitted_count == 0 { + } + if error_count > 0 { + output.warn(&format!( + "{} repositories could not be checked", + error_count + )); + } else if uncommitted_count == 0 && behind_count == 0 { output.success("All repositories are clean and up to date"); } diff --git a/src/git/shell.rs b/src/git/shell.rs index bdfc2c6..fd06c0a 100644 --- a/src/git/shell.rs +++ b/src/git/shell.rs @@ -130,6 +130,41 @@ impl ShellGit { (ahead, behind) } + /// Parses a single line of `for-each-ref` output in the format used by + /// `list_branches`: `\t\t`. + /// Returns `None` if the line has no branch name. Pure function so it + /// can be unit-tested without shelling out. + fn parse_branch_line(line: &str) -> Option { + let parts: Vec<&str> = line.splitn(3, '\t').collect(); + let name = parts.first().unwrap_or(&"").to_string(); + if name.is_empty() { + return None; + } + + let upstream_raw = parts.get(1).unwrap_or(&"").to_string(); + let track = parts.get(2).unwrap_or(&""); + let (ahead, behind) = Self::parse_track_info(track); + // `for-each-ref` emits "[gone]" in %(upstream:track) when the + // upstream ref has been deleted. %(upstream:short) still returns + // the dead ref name, so without this check is_synced would be + // true for a branch with no reachable upstream. + let upstream_gone = track.trim() == "[gone]"; + let upstream = if upstream_raw.is_empty() || upstream_gone { + None + } else { + Some(upstream_raw) + }; + let is_synced = upstream.is_some() && ahead == 0 && behind == 0; + + Some(BranchInfo { + name, + upstream, + ahead, + behind, + is_synced, + }) + } + /// Parses branch info from git status -b --porcelain output. fn parse_branch_info(&self, output: &str) -> (String, u32, u32) { let first_line = output.lines().next().unwrap_or(""); @@ -390,37 +425,11 @@ impl GitOperations for ShellGit { Some(repo_path), )?; - let mut branches = Vec::new(); - for line in output.lines() { - if line.is_empty() { - continue; - } - let parts: Vec<&str> = line.splitn(3, '\t').collect(); - let name = parts.first().unwrap_or(&"").to_string(); - if name.is_empty() { - continue; - } - - let upstream_raw = parts.get(1).unwrap_or(&"").to_string(); - let upstream = if upstream_raw.is_empty() { - None - } else { - Some(upstream_raw) - }; - - let track = parts.get(2).unwrap_or(&""); - let (ahead, behind) = Self::parse_track_info(track); - let is_synced = upstream.is_some() && ahead == 0 && behind == 0; - - branches.push(BranchInfo { - name, - upstream, - ahead, - behind, - is_synced, - }); - } - Ok(branches) + Ok(output + .lines() + .filter(|l| !l.is_empty()) + .filter_map(Self::parse_branch_line) + .collect()) } fn list_remotes(&self, repo_path: &Path) -> Result, GitError> { diff --git a/src/git/shell_tests.rs b/src/git/shell_tests.rs index 5aef11f..8597ab9 100644 --- a/src/git/shell_tests.rs +++ b/src/git/shell_tests.rs @@ -118,6 +118,65 @@ fn test_parse_track_info_diverged() { assert_eq!(behind, 7); } +#[test] +fn test_parse_track_info_gone_yields_zeros() { + // "[gone]" is the for-each-ref signal that the upstream ref no longer + // exists. parse_track_info doesn't decode it, but list_branches relies + // on it returning (0, 0) so the higher-level upstream-clearing logic + // can take over without spurious counts leaking through. + let (ahead, behind) = ShellGit::parse_track_info("[gone]"); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); +} + +#[test] +fn test_parse_branch_line_synced() { + let info = ShellGit::parse_branch_line("main\torigin/main\t").unwrap(); + assert_eq!(info.name, "main"); + assert_eq!(info.upstream, Some("origin/main".to_string())); + assert_eq!(info.ahead, 0); + assert_eq!(info.behind, 0); + assert!(info.is_synced); +} + +#[test] +fn test_parse_branch_line_no_upstream() { + let info = ShellGit::parse_branch_line("local-only\t\t").unwrap(); + assert!(info.upstream.is_none()); + assert!(!info.is_synced); +} + +#[test] +fn test_parse_branch_line_diverged() { + let info = ShellGit::parse_branch_line("feature\torigin/feature\t[ahead 2, behind 7]").unwrap(); + assert_eq!(info.upstream, Some("origin/feature".to_string())); + assert_eq!(info.ahead, 2); + assert_eq!(info.behind, 7); + assert!(!info.is_synced); +} + +#[test] +fn test_parse_branch_line_gone_upstream_not_synced() { + // When the upstream ref has been deleted, %(upstream:short) still + // returns the now-dead name and %(upstream:track) is "[gone]". The + // branch must not be reported as synced; its commits are local-only. + let info = ShellGit::parse_branch_line("orphan\torigin/orphan\t[gone]").unwrap(); + assert!( + info.upstream.is_none(), + "[gone] upstream should be cleared so the branch isn't presented as tracking a live ref" + ); + assert!( + !info.is_synced, + "branch with deleted upstream must not be reported as synced; Badge::Green would mislead the user into deleting unique commits" + ); +} + +#[test] +fn test_parse_branch_line_empty_returns_none() { + assert!(ShellGit::parse_branch_line("").is_none()); + assert!(ShellGit::parse_branch_line("\torigin/foo\t").is_none()); +} + // Integration tests that require actual git repo #[test] #[ignore] // Run with: cargo test -- --ignored diff --git a/src/git/traits.rs b/src/git/traits.rs index 44093a7..a580dbf 100644 --- a/src/git/traits.rs +++ b/src/git/traits.rs @@ -262,6 +262,10 @@ pub mod mock { pub default_status: RepoStatus, /// Custom statuses per path pub path_statuses: HashMap, + /// Paths whose `status()` call should return an error. Used by + /// `RepoScanService` tests to verify that `read_error` is captured + /// instead of being silently coerced to a clean default. + pub fail_status_paths: Vec, /// Paths that are valid repos pub valid_repos: Vec, /// Custom error message for failures @@ -286,6 +290,7 @@ pub mod mock { untracked_count: 0, }, path_statuses: HashMap::new(), + fail_status_paths: Vec::new(), valid_repos: Vec::new(), error_message: None, } @@ -433,6 +438,16 @@ pub mod mock { let path_str = repo_path.to_string_lossy().to_string(); log.status_checks.push(path_str.clone()); + if self.config.fail_status_paths.contains(&path_str) { + return Err(GitError::command_failed( + "git status", + self.config + .error_message + .as_deref() + .unwrap_or("mock status failure"), + )); + } + if let Some(status) = self.config.path_statuses.get(&path_str) { Ok(status.clone()) } else { diff --git a/src/ipc/status_file_tests.rs b/src/ipc/status_file_tests.rs index 56ca092..dece6a0 100644 --- a/src/ipc/status_file_tests.rs +++ b/src/ipc/status_file_tests.rs @@ -38,6 +38,7 @@ fn sample_status() -> FinderStatus { remotes: vec![], worktrees: Vec::new(), all_worktrees_synced: true, + read_error: None, }); status } diff --git a/src/types/finder_status.rs b/src/types/finder_status.rs index 13433d2..d533391 100644 --- a/src/types/finder_status.rs +++ b/src/types/finder_status.rs @@ -86,6 +86,12 @@ pub struct FinderRepoStatus { pub remotes: Vec, pub worktrees: Vec, pub all_worktrees_synced: bool, + /// If reading the repo's git state failed, the underlying error message. + /// Set by `scan_repo` when `git status` errors so callers (the CLI + /// `status` command, the FinderSync extension) can distinguish a broken + /// repo from a clean one. The badge for these repos is forced to `Gray`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_error: Option, } /// Classification of a GitHub account that owns repositories. diff --git a/src/types/finder_status_tests.rs b/src/types/finder_status_tests.rs index 55a8f10..04b4bc0 100644 --- a/src/types/finder_status_tests.rs +++ b/src/types/finder_status_tests.rs @@ -175,6 +175,7 @@ fn test_finder_repo_status_serialization() { }], worktrees: Vec::new(), all_worktrees_synced: true, + read_error: None, }; let json = serde_json::to_string(&repo).unwrap(); From 6364c19bd095b4d2ae67726ba37e157008723262 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 19:14:04 +0200 Subject: [PATCH 26/89] Extract RepoEntry and SyncHistoryEntry to types/ to break core to TUI coupling Both types were defined in tui::app but consumed by non-UI callers (workflows::status_scan, cache::sync_history) - making the engine modules depend on the TUI module. Move them to types::repo_status, drop the now-unnecessary feature gates on workflows::status_scan and cache::sync_history, add SyncHistoryManager to the prelude, and leave a transitional pub use shim in tui::app so existing intra-TUI imports keep compiling until B0.2. First commit of the workspace refactor (Phase B0.1). --- src/cache/mod.rs | 2 -- src/cache/sync_history.rs | 2 +- src/lib.rs | 2 +- src/tui/app.rs | 32 ++------------------------------ src/types/mod.rs | 2 ++ src/types/repo_status.rs | 36 ++++++++++++++++++++++++++++++++++++ src/workflows/mod.rs | 1 - src/workflows/status_scan.rs | 5 ++--- 8 files changed, 44 insertions(+), 38 deletions(-) create mode 100644 src/types/repo_status.rs diff --git a/src/cache/mod.rs b/src/cache/mod.rs index b49f7cc..2124e29 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,9 +1,7 @@ //! Cache and history persistence. mod discovery; -#[cfg(feature = "tui")] mod sync_history; pub use discovery::{CacheManager, DiscoveryCache, CACHE_VERSION}; -#[cfg(feature = "tui")] pub use sync_history::SyncHistoryManager; diff --git a/src/cache/sync_history.rs b/src/cache/sync_history.rs index 6f2140c..d15ec71 100644 --- a/src/cache/sync_history.rs +++ b/src/cache/sync_history.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; use tracing::debug; -use crate::tui::app::SyncHistoryEntry; +use crate::types::SyncHistoryEntry; const HISTORY_VERSION: u32 = 1; const MAX_HISTORY_ENTRIES: usize = 50; diff --git a/src/lib.rs b/src/lib.rs index 01145dd..be32837 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,7 +69,7 @@ pub mod workflows; /// Re-export commonly used types for convenience. pub mod prelude { pub use crate::auth::{get_auth, get_auth_for_provider, AuthResult}; - pub use crate::cache::{CacheManager, DiscoveryCache, CACHE_VERSION}; + pub use crate::cache::{CacheManager, DiscoveryCache, SyncHistoryManager, CACHE_VERSION}; pub use crate::cli::{Cli, Command, InitArgs, ResetArgs, StatusArgs, SyncCmdArgs}; pub use crate::config::{ Config, ConfigCloneOptions, FilterOptions, SyncMode as ConfigSyncMode, WorkspaceConfig, diff --git a/src/tui/app.rs b/src/tui/app.rs index 0a48ffb..f4c9c91 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -4,11 +4,12 @@ use crate::config::{Config, WorkspaceConfig}; use crate::setup::state::{self, SetupState}; use crate::types::{OpSummary, OwnedRepo}; use ratatui::widgets::TableState; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::time::Instant; +pub use crate::types::{RepoEntry, SyncHistoryEntry}; + /// Which screen is active. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { @@ -126,35 +127,6 @@ pub enum LogFilter { Changelog, } -/// A summary entry for sync history. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncHistoryEntry { - pub timestamp: String, - pub duration_secs: f64, - pub success: usize, - pub failed: usize, - pub skipped: usize, - pub with_updates: usize, - pub cloned: usize, - pub total_new_commits: u32, -} - -/// A local repo with its computed status. -#[derive(Debug, Clone)] -pub struct RepoEntry { - pub owner: String, - pub name: String, - pub full_name: String, - pub path: PathBuf, - pub branch: Option, - pub is_uncommitted: bool, - pub ahead: usize, - pub behind: usize, - pub staged_count: usize, - pub unstaged_count: usize, - pub untracked_count: usize, -} - /// A requirement check result for the init check screen. #[derive(Debug, Clone)] pub struct CheckEntry { diff --git a/src/types/mod.rs b/src/types/mod.rs index fdd75b2..a53260e 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -14,6 +14,7 @@ pub mod finder_status; mod provider; mod repo; +mod repo_status; pub use finder_status::{ Badge, FinderBranchInfo, FinderRemoteInfo, FinderRepoStatus, FinderStatus, FinderWorkspaceInfo, @@ -21,3 +22,4 @@ pub use finder_status::{ }; pub use provider::ProviderKind; pub use repo::{ActionPlan, OpResult, OpSummary, Org, OwnedRepo, Repo, SkippedRepo}; +pub use repo_status::{RepoEntry, SyncHistoryEntry}; diff --git a/src/types/repo_status.rs b/src/types/repo_status.rs new file mode 100644 index 0000000..5dc46b2 --- /dev/null +++ b/src/types/repo_status.rs @@ -0,0 +1,36 @@ +//! Domain types describing a local repository's git state. +//! +//! Lifted out of the TUI module so non-UI callers (`workflows::status_scan`, +//! `cache::sync_history`) can use them without depending on `tui::*`. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// A summary entry for sync history. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncHistoryEntry { + pub timestamp: String, + pub duration_secs: f64, + pub success: usize, + pub failed: usize, + pub skipped: usize, + pub with_updates: usize, + pub cloned: usize, + pub total_new_commits: u32, +} + +/// A local repo with its computed status. +#[derive(Debug, Clone)] +pub struct RepoEntry { + pub owner: String, + pub name: String, + pub full_name: String, + pub path: PathBuf, + pub branch: Option, + pub is_uncommitted: bool, + pub ahead: usize, + pub behind: usize, + pub staged_count: usize, + pub unstaged_count: usize, + pub untracked_count: usize, +} diff --git a/src/workflows/mod.rs b/src/workflows/mod.rs index 7f49840..0ca3588 100644 --- a/src/workflows/mod.rs +++ b/src/workflows/mod.rs @@ -1,5 +1,4 @@ //! Use-case workflows. -#[cfg(feature = "tui")] pub mod status_scan; pub mod sync_workspace; diff --git a/src/workflows/status_scan.rs b/src/workflows/status_scan.rs index eec4e46..0e986f1 100644 --- a/src/workflows/status_scan.rs +++ b/src/workflows/status_scan.rs @@ -3,10 +3,9 @@ use crate::config::{Config, WorkspaceConfig}; use crate::discovery::DiscoveryOrchestrator; use crate::git::{GitOperations, ShellGit}; -use crate::tui::app::RepoEntry; +use crate::types::RepoEntry; /// Scan local repositories for git status for a workspace. -#[cfg(feature = "tui")] pub fn scan_workspace_status(config: &Config, workspace: &WorkspaceConfig) -> Vec { let base_path = workspace.expanded_base_path(); if !base_path.exists() { @@ -62,6 +61,6 @@ pub fn scan_workspace_status(config: &Config, workspace: &WorkspaceConfig) -> Ve entries } -#[cfg(all(test, feature = "tui"))] +#[cfg(test)] #[path = "status_scan_tests.rs"] mod tests; From 3afa34eb75db0d0ea6dc1bc74947dbf70069e546 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 19:16:21 +0200 Subject: [PATCH 27/89] Rewrite intra-TUI imports to source RepoEntry and SyncHistoryEntry from types:: Removes the transitional pub use shim in tui::app and points each consumer (event, event_tests, screens/dashboard, handler, app itself) at the new canonical home in crate::types. With this commit, no TUI-internal code relies on tui::app re-exporting domain types. Phase B0.2 of the workspace refactor. --- src/tui/app.rs | 4 +--- src/tui/event.rs | 4 ++-- src/tui/event_tests.rs | 4 ++-- src/tui/handler.rs | 4 ++-- src/tui/screens/dashboard.rs | 3 ++- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index f4c9c91..18b2d12 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -2,14 +2,12 @@ use crate::config::{Config, WorkspaceConfig}; use crate::setup::state::{self, SetupState}; -use crate::types::{OpSummary, OwnedRepo}; +use crate::types::{OpSummary, OwnedRepo, RepoEntry, SyncHistoryEntry}; use ratatui::widgets::TableState; use std::collections::HashMap; use std::path::PathBuf; use std::time::Instant; -pub use crate::types::{RepoEntry, SyncHistoryEntry}; - /// Which screen is active. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { diff --git a/src/tui/event.rs b/src/tui/event.rs index b590ed0..1a6de6f 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -6,9 +6,9 @@ use tokio::sync::mpsc; use tracing::warn; use crate::setup::state::OrgEntry; -use crate::types::{OpSummary, OwnedRepo}; +use crate::types::{OpSummary, OwnedRepo, RepoEntry}; -use super::app::{CheckEntry, Operation, RepoEntry}; +use super::app::{CheckEntry, Operation}; /// Events that the TUI loop processes. #[derive(Debug)] diff --git a/src/tui/event_tests.rs b/src/tui/event_tests.rs index 84a8392..0071b5b 100644 --- a/src/tui/event_tests.rs +++ b/src/tui/event_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::setup::state::OrgEntry; -use crate::tui::app::{CheckEntry, Operation, RepoEntry}; -use crate::types::OpSummary; +use crate::tui::app::{CheckEntry, Operation}; +use crate::types::{OpSummary, RepoEntry}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::path::PathBuf; diff --git a/src/tui/handler.rs b/src/tui/handler.rs index 1828c50..2f23439 100644 --- a/src/tui/handler.rs +++ b/src/tui/handler.rs @@ -4,9 +4,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tokio::sync::mpsc::UnboundedSender; use super::app::{ - App, CheckEntry, LogFilter, Operation, OperationState, Screen, SyncHistoryEntry, SyncLogEntry, - SyncLogStatus, + App, CheckEntry, LogFilter, Operation, OperationState, Screen, SyncLogEntry, SyncLogStatus, }; +use crate::types::SyncHistoryEntry; use super::event::{AppEvent, BackendMessage}; use super::screens; use crate::cache::SyncHistoryManager; diff --git a/src/tui/screens/dashboard.rs b/src/tui/screens/dashboard.rs index e862d18..ec59350 100644 --- a/src/tui/screens/dashboard.rs +++ b/src/tui/screens/dashboard.rs @@ -16,7 +16,8 @@ use crossterm::event::{KeyCode, KeyEvent}; use tokio::sync::mpsc::UnboundedSender; use crate::banner::{render_animated_banner, render_banner}; -use crate::tui::app::{App, Operation, OperationState, RepoEntry, Screen}; +use crate::tui::app::{App, Operation, OperationState, Screen}; +use crate::types::RepoEntry; use crate::tui::event::AppEvent; // ── Key handler ───────────────────────────────────────────────────────────── From 5ef18ffe0a42c17da4710f3ce6eb7150183df97d Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 19:16:47 +0200 Subject: [PATCH 28/89] Drop lib-level cfg gate from pub mod setup The setup module is feature-gated as tui-only at the lib root, but the gating that actually matters is on its internal ratatui usage and on the CLI Setup subcommand wiring. Removing the lib-level cfg is a no-op for behavior (the inner ratatui imports still gate compilation when the tui feature is off, same as before) and prepares for B3, where setup moves into the CLI crate and the cfg lands on its declaration there. Phase B0.3 of the workspace refactor. --- src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index be32837..879720a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,7 +59,6 @@ pub mod ipc; pub mod operations; pub mod output; pub mod provider; -#[cfg(feature = "tui")] pub mod setup; #[cfg(feature = "tui")] pub mod tui; From 2607e6e2b3a69197c54535cd61fbb9c35aa62387 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 19:18:32 +0200 Subject: [PATCH 29/89] Apply cargo fmt to reorder imports introduced in B0.2 cargo fmt rearranges the new use crate::types::* lines to follow rustfmt's external/crate ordering. Pure formatting; no behavior change. --- src/tui/handler.rs | 2 +- src/tui/screens/dashboard.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/handler.rs b/src/tui/handler.rs index 2f23439..d2df5dc 100644 --- a/src/tui/handler.rs +++ b/src/tui/handler.rs @@ -6,13 +6,13 @@ use tokio::sync::mpsc::UnboundedSender; use super::app::{ App, CheckEntry, LogFilter, Operation, OperationState, Screen, SyncLogEntry, SyncLogStatus, }; -use crate::types::SyncHistoryEntry; use super::event::{AppEvent, BackendMessage}; use super::screens; use crate::cache::SyncHistoryManager; use crate::config::WorkspaceManager; use crate::domain::RepoPathTemplate; use crate::setup::state::{SetupOutcome, SetupStep}; +use crate::types::SyncHistoryEntry; const MAX_THROUGHPUT_SAMPLES: usize = 240; const MAX_LOG_LINES: usize = 5_000; diff --git a/src/tui/screens/dashboard.rs b/src/tui/screens/dashboard.rs index ec59350..3c2cf23 100644 --- a/src/tui/screens/dashboard.rs +++ b/src/tui/screens/dashboard.rs @@ -17,8 +17,8 @@ use tokio::sync::mpsc::UnboundedSender; use crate::banner::{render_animated_banner, render_banner}; use crate::tui::app::{App, Operation, OperationState, Screen}; -use crate::types::RepoEntry; use crate::tui::event::AppEvent; +use crate::types::RepoEntry; // ── Key handler ───────────────────────────────────────────────────────────── From ac8bebdb2d970c1d08c169089c24f7d4db808c8b Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 19:32:57 +0200 Subject: [PATCH 30/89] Split into Cargo workspace: git-same-core (engine) + git-same-cli (binary) Restructures the project from a single git-same crate into a two-member workspace so the engine library can be reused by future frontends (Phase C Tauri app, headless daemon split, etc.) without those frontends being able to accidentally pull in clap/ratatui types. What moves where (B2/B3): - crates/git-same-core/ api, auth, cache, checks, config, discovery, domain, errors, git, infra, ipc, operations, output, provider, types, workflows - crates/git-same-cli/ main.rs, lib.rs, cli.rs, banner.rs, commands/, setup/, tui/, app/, bin/gen_* Boundary changes: - Root Cargo.toml is now a pure workspace manifest with hoisted deps and workspace.package shared metadata. - git-same-core has no UI deps (no clap, ratatui, crossterm, console). - git-same-cli depends on git-same-core via a path/version pin. - gen-completions and gen-manpage move with the CLI crate; their required-features = ["release-tools"] gating is preserved. - The binary still publishes as `git-same` ([[bin]].name) so installer aliases (gisa, gitsa, gitsame) and target/release/git-same path are unchanged. Cross-crate test helpers: - Repo::test() and rand_id() are now gated on `cfg(any(test, feature = "test-utils"))` so the CLI crate's tests can build them by enabling the test-utils feature on its dev-dependency on core. Integration test fix (B4 folded in): - The test now reads CARGO_BIN_EXE_git-same via env!() (compile-time) instead of std::env::var_os (runtime). The runtime variant only worked in the single-crate layout because the broken fallback path happened to resolve correctly there. Doc-test paths: - All `use git_same::*` references inside core's doc comments rewritten to `use git_same_core::*`. Phase B1 + B2 + B3 + B4 of the workspace refactor. --- Cargo.lock | 26 ++++- Cargo.toml | 75 ++----------- crates/git-same-cli/Cargo.toml | 58 ++++++++++ .../git-same-cli/src}/app/cli/mod.rs | 0 {src => crates/git-same-cli/src}/app/mod.rs | 0 .../git-same-cli/src}/app/tui/mod.rs | 0 {src => crates/git-same-cli/src}/banner.rs | 0 .../git-same-cli/src}/banner_tests.rs | 0 .../git-same-cli/src}/bin/gen_completions.rs | 2 +- .../git-same-cli/src}/bin/gen_manpage.rs | 2 +- {src => crates/git-same-cli/src}/cli.rs | 0 {src => crates/git-same-cli/src}/cli_tests.rs | 0 .../git-same-cli/src}/commands/daemon.rs | 28 ++--- .../src}/commands/daemon_tests.rs | 0 .../git-same-cli/src}/commands/init.rs | 8 +- .../git-same-cli/src}/commands/init_tests.rs | 2 +- .../git-same-cli/src}/commands/mod.rs | 6 +- .../git-same-cli/src}/commands/refresh.rs | 8 +- .../src}/commands/refresh_tests.rs | 2 +- .../git-same-cli/src}/commands/reset.rs | 14 +-- .../git-same-cli/src}/commands/reset_tests.rs | 6 +- .../git-same-cli/src}/commands/scan.rs | 8 +- .../git-same-cli/src}/commands/scan_tests.rs | 12 ++- .../git-same-cli/src}/commands/setup.rs | 4 +- .../git-same-cli/src}/commands/status.rs | 10 +- .../src}/commands/status_tests.rs | 2 +- .../src}/commands/support/concurrency.rs | 4 +- .../commands/support/concurrency_tests.rs | 2 +- .../git-same-cli/src}/commands/support/mod.rs | 0 .../src}/commands/support/workspace.rs | 6 +- .../src}/commands/support/workspace_tests.rs | 2 +- .../git-same-cli/src}/commands/sync_cmd.rs | 14 +-- .../src}/commands/sync_cmd_tests.rs | 2 +- .../git-same-cli/src}/commands/workspace.rs | 10 +- .../src}/commands/workspace_tests.rs | 2 +- crates/git-same-cli/src/lib.rs | 14 +++ {src => crates/git-same-cli/src}/main.rs | 12 +-- .../git-same-cli/src}/main_tests.rs | 2 +- .../git-same-cli/src}/setup/handler.rs | 12 +-- .../git-same-cli/src}/setup/handler_tests.rs | 6 +- {src => crates/git-same-cli/src}/setup/mod.rs | 8 +- .../git-same-cli/src}/setup/mod_tests.rs | 6 +- .../git-same-cli/src}/setup/screens/auth.rs | 0 .../src}/setup/screens/auth_tests.rs | 0 .../src}/setup/screens/complete.rs | 0 .../src}/setup/screens/complete_tests.rs | 0 .../src}/setup/screens/confirm.rs | 0 .../src}/setup/screens/confirm_tests.rs | 0 .../git-same-cli/src}/setup/screens/mod.rs | 0 .../src}/setup/screens/mod_tests.rs | 0 .../git-same-cli/src}/setup/screens/orgs.rs | 0 .../src}/setup/screens/orgs_tests.rs | 0 .../git-same-cli/src}/setup/screens/path.rs | 0 .../src}/setup/screens/path_tests.rs | 0 .../src}/setup/screens/provider.rs | 2 +- .../src}/setup/screens/provider_tests.rs | 2 +- .../src}/setup/screens/requirements.rs | 0 .../src}/setup/screens/requirements_tests.rs | 4 +- .../git-same-cli/src}/setup/state.rs | 6 +- .../git-same-cli/src}/setup/state_tests.rs | 6 +- {src => crates/git-same-cli/src}/setup/ui.rs | 0 .../git-same-cli/src}/setup/ui_tests.rs | 0 {src => crates/git-same-cli/src}/tui/app.rs | 13 +-- .../git-same-cli/src}/tui/app_tests.rs | 0 .../git-same-cli/src}/tui/backend.rs | 16 +-- .../git-same-cli/src}/tui/backend_tests.rs | 12 +-- {src => crates/git-same-cli/src}/tui/event.rs | 2 +- .../git-same-cli/src}/tui/event_tests.rs | 4 +- .../git-same-cli/src}/tui/handler.rs | 14 +-- .../git-same-cli/src}/tui/handler_tests.rs | 2 +- {src => crates/git-same-cli/src}/tui/mod.rs | 4 +- .../src}/tui/screens/dashboard.rs | 2 +- .../src}/tui/screens/dashboard_tests.rs | 2 +- .../git-same-cli/src}/tui/screens/mod.rs | 0 .../git-same-cli/src}/tui/screens/settings.rs | 4 +- .../src}/tui/screens/settings_tests.rs | 2 +- .../git-same-cli/src}/tui/screens/sync.rs | 0 .../src}/tui/screens/sync_tests.rs | 4 +- .../src}/tui/screens/workspaces.rs | 8 +- .../src}/tui/screens/workspaces_tests.rs | 0 {src => crates/git-same-cli/src}/tui/ui.rs | 0 .../git-same-cli/src}/tui/widgets/mod.rs | 0 .../src}/tui/widgets/repo_table.rs | 2 +- .../src}/tui/widgets/repo_table_tests.rs | 4 +- .../src}/tui/widgets/status_bar.rs | 0 .../git-same-cli/tests}/integration_test.rs | 10 +- crates/git-same-core/Cargo.toml | 37 +++++++ .../src}/api/ambient_upgrade_cache.rs | 0 .../src}/api/ambient_upgrade_cache_tests.rs | 0 {src => crates/git-same-core/src}/api/mod.rs | 0 .../src}/api/owner_type_cache.rs | 0 .../src}/api/owner_type_cache_tests.rs | 0 .../git-same-core/src}/api/service.rs | 0 .../git-same-core/src}/api/service_tests.rs | 0 .../git-same-core/src}/auth/gh_cli.rs | 0 .../git-same-core/src}/auth/gh_cli_tests.rs | 0 {src => crates/git-same-core/src}/auth/mod.rs | 0 .../git-same-core/src}/auth/mod_tests.rs | 0 .../git-same-core/src}/auth/process.rs | 0 .../git-same-core/src}/auth/process_tests.rs | 0 {src => crates/git-same-core/src}/auth/ssh.rs | 0 .../git-same-core/src}/auth/ssh_tests.rs | 0 .../git-same-core/src}/cache/discovery.rs | 0 .../src}/cache/discovery_tests.rs | 0 .../git-same-core/src}/cache/mod.rs | 0 .../git-same-core/src}/cache/sync_history.rs | 0 .../src}/cache/sync_history_tests.rs | 0 {src => crates/git-same-core/src}/checks.rs | 0 .../git-same-core/src}/checks_tests.rs | 0 .../git-same-core/src}/config/mod.rs | 0 .../git-same-core/src}/config/parser.rs | 0 .../git-same-core/src}/config/parser_tests.rs | 0 .../src}/config/provider_config.rs | 0 .../src}/config/provider_config_tests.rs | 0 .../git-same-core/src}/config/workspace.rs | 0 .../src}/config/workspace_manager.rs | 0 .../src}/config/workspace_manager_tests.rs | 0 .../src}/config/workspace_policy.rs | 0 .../src}/config/workspace_policy_tests.rs | 0 .../src}/config/workspace_store.rs | 0 .../src}/config/workspace_store_tests.rs | 0 .../src}/config/workspace_tests.rs | 0 .../git-same-core/src}/discovery.rs | 0 .../git-same-core/src}/discovery_tests.rs | 0 .../git-same-core/src}/domain/mod.rs | 0 .../src}/domain/repo_path_template.rs | 0 .../src}/domain/repo_path_template_tests.rs | 0 .../git-same-core/src}/errors/app.rs | 0 .../git-same-core/src}/errors/app_tests.rs | 0 .../git-same-core/src}/errors/git.rs | 0 .../git-same-core/src}/errors/git_tests.rs | 0 .../git-same-core/src}/errors/mod.rs | 2 +- .../git-same-core/src}/errors/provider.rs | 0 .../src}/errors/provider_tests.rs | 0 {src => crates/git-same-core/src}/git/mod.rs | 2 +- .../git-same-core/src}/git/mod_tests.rs | 0 .../git-same-core/src}/git/shell.rs | 0 .../git-same-core/src}/git/shell_tests.rs | 0 .../git-same-core/src}/git/traits.rs | 0 .../git-same-core/src}/git/traits_tests.rs | 0 .../git-same-core/src}/infra/mod.rs | 0 .../git-same-core/src}/infra/storage/mod.rs | 0 {src => crates/git-same-core/src}/ipc/mod.rs | 0 .../git-same-core/src}/ipc/mod_tests.rs | 0 .../git-same-core/src}/ipc/status_file.rs | 0 .../src}/ipc/status_file_tests.rs | 0 .../git-same-core/src}/ipc/unix_socket.rs | 0 .../src}/ipc/unix_socket_tests.rs | 0 crates/git-same-core/src/lib.rs | 58 ++++++++++ .../git-same-core/src}/lib_tests.rs | 0 .../git-same-core/src}/operations/clone.rs | 4 +- .../src}/operations/clone_tests.rs | 0 .../git-same-core/src}/operations/mod.rs | 0 .../git-same-core/src}/operations/sync.rs | 6 +- .../src}/operations/sync_tests.rs | 0 .../git-same-core/src}/output/mod.rs | 0 .../git-same-core/src}/output/printer.rs | 0 .../src}/output/printer_tests.rs | 0 .../src}/output/progress/clone.rs | 0 .../src}/output/progress/clone_tests.rs | 0 .../src}/output/progress/discovery.rs | 0 .../src}/output/progress/discovery_tests.rs | 0 .../git-same-core/src}/output/progress/mod.rs | 0 .../src}/output/progress/styles.rs | 0 .../src}/output/progress/sync.rs | 0 .../src}/output/progress/sync_tests.rs | 0 .../src}/provider/github/client.rs | 0 .../src}/provider/github/client_tests.rs | 0 .../git-same-core/src}/provider/github/mod.rs | 0 .../src}/provider/github/pagination.rs | 0 .../src}/provider/github/pagination_tests.rs | 0 .../git-same-core/src}/provider/mock.rs | 0 .../git-same-core/src}/provider/mock_tests.rs | 0 .../git-same-core/src}/provider/mod.rs | 6 +- .../git-same-core/src}/provider/mod_tests.rs | 0 .../git-same-core/src}/provider/traits.rs | 0 .../src}/provider/traits_tests.rs | 0 .../git-same-core/src}/types/finder_status.rs | 0 .../src}/types/finder_status_tests.rs | 0 .../git-same-core/src}/types/mod.rs | 0 .../git-same-core/src}/types/provider.rs | 0 .../src}/types/provider_tests.rs | 0 .../git-same-core/src}/types/repo.rs | 4 +- .../git-same-core/src}/types/repo_status.rs | 0 .../git-same-core/src}/types/repo_tests.rs | 0 .../git-same-core/src}/workflows/mod.rs | 0 .../src}/workflows/status_scan.rs | 0 .../src}/workflows/status_scan_tests.rs | 0 .../src}/workflows/sync_workspace.rs | 0 .../src}/workflows/sync_workspace_tests.rs | 0 src/lib.rs | 101 ------------------ 191 files changed, 374 insertions(+), 346 deletions(-) create mode 100644 crates/git-same-cli/Cargo.toml rename {src => crates/git-same-cli/src}/app/cli/mod.rs (100%) rename {src => crates/git-same-cli/src}/app/mod.rs (100%) rename {src => crates/git-same-cli/src}/app/tui/mod.rs (100%) rename {src => crates/git-same-cli/src}/banner.rs (100%) rename {src => crates/git-same-cli/src}/banner_tests.rs (100%) rename {src => crates/git-same-cli/src}/bin/gen_completions.rs (97%) rename {src => crates/git-same-cli/src}/bin/gen_manpage.rs (94%) rename {src => crates/git-same-cli/src}/cli.rs (100%) rename {src => crates/git-same-cli/src}/cli_tests.rs (100%) rename {src => crates/git-same-cli/src}/commands/daemon.rs (93%) rename {src => crates/git-same-cli/src}/commands/daemon_tests.rs (100%) rename {src => crates/git-same-cli/src}/commands/init.rs (93%) rename {src => crates/git-same-cli/src}/commands/init_tests.rs (97%) rename {src => crates/git-same-cli/src}/commands/mod.rs (95%) rename {src => crates/git-same-cli/src}/commands/refresh.rs (88%) rename {src => crates/git-same-cli/src}/commands/refresh_tests.rs (93%) rename {src => crates/git-same-cli/src}/commands/reset.rs (96%) rename {src => crates/git-same-cli/src}/commands/reset_tests.rs (92%) rename {src => crates/git-same-cli/src}/commands/scan.rs (96%) rename {src => crates/git-same-cli/src}/commands/scan_tests.rs (92%) rename {src => crates/git-same-cli/src}/commands/setup.rs (89%) rename {src => crates/git-same-cli/src}/commands/status.rs (95%) rename {src => crates/git-same-cli/src}/commands/status_tests.rs (95%) rename {src => crates/git-same-cli/src}/commands/support/concurrency.rs (85%) rename {src => crates/git-same-cli/src}/commands/support/concurrency_tests.rs (92%) rename {src => crates/git-same-cli/src}/commands/support/mod.rs (100%) rename {src => crates/git-same-cli/src}/commands/support/workspace.rs (87%) rename {src => crates/git-same-cli/src}/commands/support/workspace_tests.rs (95%) rename {src => crates/git-same-cli/src}/commands/sync_cmd.rs (94%) rename {src => crates/git-same-cli/src}/commands/sync_cmd_tests.rs (98%) rename {src => crates/git-same-cli/src}/commands/workspace.rs (90%) rename {src => crates/git-same-cli/src}/commands/workspace_tests.rs (95%) create mode 100644 crates/git-same-cli/src/lib.rs rename {src => crates/git-same-cli/src}/main.rs (91%) rename {src => crates/git-same-cli/src}/main_tests.rs (94%) rename {src => crates/git-same-cli/src}/setup/handler.rs (98%) rename {src => crates/git-same-cli/src}/setup/handler_tests.rs (99%) rename {src => crates/git-same-cli/src}/setup/mod.rs (95%) rename {src => crates/git-same-cli/src}/setup/mod_tests.rs (92%) rename {src => crates/git-same-cli/src}/setup/screens/auth.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/auth_tests.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/complete.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/complete_tests.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/confirm.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/confirm_tests.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/mod.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/mod_tests.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/orgs.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/orgs_tests.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/path.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/path_tests.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/provider.rs (98%) rename {src => crates/git-same-cli/src}/setup/screens/provider_tests.rs (97%) rename {src => crates/git-same-cli/src}/setup/screens/requirements.rs (100%) rename {src => crates/git-same-cli/src}/setup/screens/requirements_tests.rs (95%) rename {src => crates/git-same-cli/src}/setup/state.rs (98%) rename {src => crates/git-same-cli/src}/setup/state_tests.rs (97%) rename {src => crates/git-same-cli/src}/setup/ui.rs (100%) rename {src => crates/git-same-cli/src}/setup/ui_tests.rs (100%) rename {src => crates/git-same-cli/src}/tui/app.rs (96%) rename {src => crates/git-same-cli/src}/tui/app_tests.rs (100%) rename {src => crates/git-same-cli/src}/tui/backend.rs (96%) rename {src => crates/git-same-cli/src}/tui/backend_tests.rs (96%) rename {src => crates/git-same-cli/src}/tui/event.rs (98%) rename {src => crates/git-same-cli/src}/tui/event_tests.rs (96%) rename {src => crates/git-same-cli/src}/tui/handler.rs (98%) rename {src => crates/git-same-cli/src}/tui/handler_tests.rs (98%) rename {src => crates/git-same-cli/src}/tui/mod.rs (96%) rename {src => crates/git-same-cli/src}/tui/screens/dashboard.rs (99%) rename {src => crates/git-same-cli/src}/tui/screens/dashboard_tests.rs (99%) rename {src => crates/git-same-cli/src}/tui/screens/mod.rs (100%) rename {src => crates/git-same-cli/src}/tui/screens/settings.rs (98%) rename {src => crates/git-same-cli/src}/tui/screens/settings_tests.rs (97%) rename {src => crates/git-same-cli/src}/tui/screens/sync.rs (100%) rename {src => crates/git-same-cli/src}/tui/screens/sync_tests.rs (95%) rename {src => crates/git-same-cli/src}/tui/screens/workspaces.rs (98%) rename {src => crates/git-same-cli/src}/tui/screens/workspaces_tests.rs (100%) rename {src => crates/git-same-cli/src}/tui/ui.rs (100%) rename {src => crates/git-same-cli/src}/tui/widgets/mod.rs (100%) rename {src => crates/git-same-cli/src}/tui/widgets/repo_table.rs (97%) rename {src => crates/git-same-cli/src}/tui/widgets/repo_table_tests.rs (87%) rename {src => crates/git-same-cli/src}/tui/widgets/status_bar.rs (100%) rename {tests => crates/git-same-cli/tests}/integration_test.rs (98%) create mode 100644 crates/git-same-core/Cargo.toml rename {src => crates/git-same-core/src}/api/ambient_upgrade_cache.rs (100%) rename {src => crates/git-same-core/src}/api/ambient_upgrade_cache_tests.rs (100%) rename {src => crates/git-same-core/src}/api/mod.rs (100%) rename {src => crates/git-same-core/src}/api/owner_type_cache.rs (100%) rename {src => crates/git-same-core/src}/api/owner_type_cache_tests.rs (100%) rename {src => crates/git-same-core/src}/api/service.rs (100%) rename {src => crates/git-same-core/src}/api/service_tests.rs (100%) rename {src => crates/git-same-core/src}/auth/gh_cli.rs (100%) rename {src => crates/git-same-core/src}/auth/gh_cli_tests.rs (100%) rename {src => crates/git-same-core/src}/auth/mod.rs (100%) rename {src => crates/git-same-core/src}/auth/mod_tests.rs (100%) rename {src => crates/git-same-core/src}/auth/process.rs (100%) rename {src => crates/git-same-core/src}/auth/process_tests.rs (100%) rename {src => crates/git-same-core/src}/auth/ssh.rs (100%) rename {src => crates/git-same-core/src}/auth/ssh_tests.rs (100%) rename {src => crates/git-same-core/src}/cache/discovery.rs (100%) rename {src => crates/git-same-core/src}/cache/discovery_tests.rs (100%) rename {src => crates/git-same-core/src}/cache/mod.rs (100%) rename {src => crates/git-same-core/src}/cache/sync_history.rs (100%) rename {src => crates/git-same-core/src}/cache/sync_history_tests.rs (100%) rename {src => crates/git-same-core/src}/checks.rs (100%) rename {src => crates/git-same-core/src}/checks_tests.rs (100%) rename {src => crates/git-same-core/src}/config/mod.rs (100%) rename {src => crates/git-same-core/src}/config/parser.rs (100%) rename {src => crates/git-same-core/src}/config/parser_tests.rs (100%) rename {src => crates/git-same-core/src}/config/provider_config.rs (100%) rename {src => crates/git-same-core/src}/config/provider_config_tests.rs (100%) rename {src => crates/git-same-core/src}/config/workspace.rs (100%) rename {src => crates/git-same-core/src}/config/workspace_manager.rs (100%) rename {src => crates/git-same-core/src}/config/workspace_manager_tests.rs (100%) rename {src => crates/git-same-core/src}/config/workspace_policy.rs (100%) rename {src => crates/git-same-core/src}/config/workspace_policy_tests.rs (100%) rename {src => crates/git-same-core/src}/config/workspace_store.rs (100%) rename {src => crates/git-same-core/src}/config/workspace_store_tests.rs (100%) rename {src => crates/git-same-core/src}/config/workspace_tests.rs (100%) rename {src => crates/git-same-core/src}/discovery.rs (100%) rename {src => crates/git-same-core/src}/discovery_tests.rs (100%) rename {src => crates/git-same-core/src}/domain/mod.rs (100%) rename {src => crates/git-same-core/src}/domain/repo_path_template.rs (100%) rename {src => crates/git-same-core/src}/domain/repo_path_template_tests.rs (100%) rename {src => crates/git-same-core/src}/errors/app.rs (100%) rename {src => crates/git-same-core/src}/errors/app_tests.rs (100%) rename {src => crates/git-same-core/src}/errors/git.rs (100%) rename {src => crates/git-same-core/src}/errors/git_tests.rs (100%) rename {src => crates/git-same-core/src}/errors/mod.rs (91%) rename {src => crates/git-same-core/src}/errors/provider.rs (100%) rename {src => crates/git-same-core/src}/errors/provider_tests.rs (100%) rename {src => crates/git-same-core/src}/git/mod.rs (94%) rename {src => crates/git-same-core/src}/git/mod_tests.rs (100%) rename {src => crates/git-same-core/src}/git/shell.rs (100%) rename {src => crates/git-same-core/src}/git/shell_tests.rs (100%) rename {src => crates/git-same-core/src}/git/traits.rs (100%) rename {src => crates/git-same-core/src}/git/traits_tests.rs (100%) rename {src => crates/git-same-core/src}/infra/mod.rs (100%) rename {src => crates/git-same-core/src}/infra/storage/mod.rs (100%) rename {src => crates/git-same-core/src}/ipc/mod.rs (100%) rename {src => crates/git-same-core/src}/ipc/mod_tests.rs (100%) rename {src => crates/git-same-core/src}/ipc/status_file.rs (100%) rename {src => crates/git-same-core/src}/ipc/status_file_tests.rs (100%) rename {src => crates/git-same-core/src}/ipc/unix_socket.rs (100%) rename {src => crates/git-same-core/src}/ipc/unix_socket_tests.rs (100%) create mode 100644 crates/git-same-core/src/lib.rs rename {src => crates/git-same-core/src}/lib_tests.rs (100%) rename {src => crates/git-same-core/src}/operations/clone.rs (98%) rename {src => crates/git-same-core/src}/operations/clone_tests.rs (100%) rename {src => crates/git-same-core/src}/operations/mod.rs (100%) rename {src => crates/git-same-core/src}/operations/sync.rs (99%) rename {src => crates/git-same-core/src}/operations/sync_tests.rs (100%) rename {src => crates/git-same-core/src}/output/mod.rs (100%) rename {src => crates/git-same-core/src}/output/printer.rs (100%) rename {src => crates/git-same-core/src}/output/printer_tests.rs (100%) rename {src => crates/git-same-core/src}/output/progress/clone.rs (100%) rename {src => crates/git-same-core/src}/output/progress/clone_tests.rs (100%) rename {src => crates/git-same-core/src}/output/progress/discovery.rs (100%) rename {src => crates/git-same-core/src}/output/progress/discovery_tests.rs (100%) rename {src => crates/git-same-core/src}/output/progress/mod.rs (100%) rename {src => crates/git-same-core/src}/output/progress/styles.rs (100%) rename {src => crates/git-same-core/src}/output/progress/sync.rs (100%) rename {src => crates/git-same-core/src}/output/progress/sync_tests.rs (100%) rename {src => crates/git-same-core/src}/provider/github/client.rs (100%) rename {src => crates/git-same-core/src}/provider/github/client_tests.rs (100%) rename {src => crates/git-same-core/src}/provider/github/mod.rs (100%) rename {src => crates/git-same-core/src}/provider/github/pagination.rs (100%) rename {src => crates/git-same-core/src}/provider/github/pagination_tests.rs (100%) rename {src => crates/git-same-core/src}/provider/mock.rs (100%) rename {src => crates/git-same-core/src}/provider/mock_tests.rs (100%) rename {src => crates/git-same-core/src}/provider/mod.rs (89%) rename {src => crates/git-same-core/src}/provider/mod_tests.rs (100%) rename {src => crates/git-same-core/src}/provider/traits.rs (100%) rename {src => crates/git-same-core/src}/provider/traits_tests.rs (100%) rename {src => crates/git-same-core/src}/types/finder_status.rs (100%) rename {src => crates/git-same-core/src}/types/finder_status_tests.rs (100%) rename {src => crates/git-same-core/src}/types/mod.rs (100%) rename {src => crates/git-same-core/src}/types/provider.rs (100%) rename {src => crates/git-same-core/src}/types/provider_tests.rs (100%) rename {src => crates/git-same-core/src}/types/repo.rs (98%) rename {src => crates/git-same-core/src}/types/repo_status.rs (100%) rename {src => crates/git-same-core/src}/types/repo_tests.rs (100%) rename {src => crates/git-same-core/src}/workflows/mod.rs (100%) rename {src => crates/git-same-core/src}/workflows/status_scan.rs (100%) rename {src => crates/git-same-core/src}/workflows/status_scan_tests.rs (100%) rename {src => crates/git-same-core/src}/workflows/sync_workspace.rs (100%) rename {src => crates/git-same-core/src}/workflows/sync_workspace_tests.rs (100%) delete mode 100644 src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f8e7877..1d5aa55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -863,22 +863,42 @@ dependencies = [ ] [[package]] -name = "git-same" +name = "git-same-cli" version = "3.1.0" dependencies = [ "anyhow", - "async-trait", "chrono", "clap", "clap_complete", "clap_mangen", "console", "crossterm", + "git-same-core", + "indicatif", + "ratatui", + "serde", + "serde_json", + "shellexpand", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "git-same-core" +version = "3.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "console", "directories", "futures", "indicatif", "mockito", - "ratatui", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 34031f0..f1f9466 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,91 +1,36 @@ -[package] -name = "git-same" +[workspace] +members = ["crates/git-same-core", "crates/git-same-cli"] +resolver = "2" + +[workspace.package] version = "3.1.0" edition = "2021" authors = ["Git-Same Contributors"] -description = "Mirror GitHub structure /orgs/repos/ to local file system." license = "MIT" repository = "https://github.com/zaai-com/git-same" -keywords = ["git", "github", "cli", "clone", "sync"] -categories = ["command-line-utilities", "development-tools"] - -# Aliases (gitsame, gitsa, gisa) are created as symlinks by installers. -# See toolkit/packaging/binary-aliases.txt for the full list. -[[bin]] -name = "git-same" -path = "src/main.rs" - -# Release-only helpers. Gated behind the `release-tools` feature so they don't -# bloat normal `cargo build` and don't pull in clap_complete/clap_mangen for -# end-user installs. Drives the completions + manpage shipped in release tarballs. -[[bin]] -name = "gen-completions" -path = "src/bin/gen_completions.rs" -required-features = ["release-tools"] - -[[bin]] -name = "gen-manpage" -path = "src/bin/gen_manpage.rs" -required-features = ["release-tools"] - -[features] -default = ["tui"] -tui = ["dep:ratatui", "dep:crossterm"] -release-tools = ["dep:clap_complete", "dep:clap_mangen"] -[dependencies] -# CLI parsing +[workspace.dependencies] clap = { version = "4", features = ["derive"] } - - -# Async runtime tokio = { version = "1", features = ["full"] } - -# HTTP client for GitHub API reqwest = { version = "0.13", features = ["json"] } - -# JSON/TOML serialization serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "1" - -# Progress bars and terminal output indicatif = "0.18" console = "0.16" - -# XDG directories (~/.config/git-same) directories = "6" - -# Error handling thiserror = "2" anyhow = "1" - -# Shell expansion (~/ paths) shellexpand = "3" - -# Async trait support async-trait = "0.1" - -# Date/time handling chrono = { version = "0.4", features = ["serde"] } - -# Futures utilities futures = "0.3" - -# Structured logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# TUI (optional, behind "tui" feature) -ratatui = { version = "0.30", optional = true } -crossterm = { version = "0.29", optional = true } - -# Release tooling (optional, behind "release-tools" feature) -clap_complete = { version = "4", optional = true } -clap_mangen = { version = "0.3", optional = true } - -[dev-dependencies] -# Testing +ratatui = "0.30" +crossterm = "0.29" +clap_complete = "4" +clap_mangen = "0.3" tokio-test = "0.4" mockito = "1" tempfile = "3" diff --git a/crates/git-same-cli/Cargo.toml b/crates/git-same-cli/Cargo.toml new file mode 100644 index 0000000..43c6417 --- /dev/null +++ b/crates/git-same-cli/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "git-same-cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["git", "github", "cli", "clone", "sync"] +categories = ["command-line-utilities"] +description = "Mirror GitHub structure /orgs/repos/ to local file system." + +# Naming notes: +# - Package on crates.io: git-same-cli (cargo install git-same-cli) +# - Library name (auto-derived): git_same_cli +# - Binary produced: git-same (filesystem aliases: gisa, gitsa, gitsame) + +[[bin]] +name = "git-same" +path = "src/main.rs" + +[[bin]] +name = "gen-completions" +path = "src/bin/gen_completions.rs" +required-features = ["release-tools"] + +[[bin]] +name = "gen-manpage" +path = "src/bin/gen_manpage.rs" +required-features = ["release-tools"] + +[features] +default = ["tui"] +tui = ["dep:ratatui", "dep:crossterm"] +release-tools = ["dep:clap_complete", "dep:clap_mangen"] + +[dependencies] +git-same-core = { path = "../git-same-core", version = "=3.1.0" } +clap = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +indicatif = { workspace = true } +console = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +shellexpand = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +ratatui = { workspace = true, optional = true } +crossterm = { workspace = true, optional = true } +clap_complete = { workspace = true, optional = true } +clap_mangen = { workspace = true, optional = true } + +[dev-dependencies] +git-same-core = { path = "../git-same-core", features = ["test-utils"] } +tempfile = { workspace = true } diff --git a/src/app/cli/mod.rs b/crates/git-same-cli/src/app/cli/mod.rs similarity index 100% rename from src/app/cli/mod.rs rename to crates/git-same-cli/src/app/cli/mod.rs diff --git a/src/app/mod.rs b/crates/git-same-cli/src/app/mod.rs similarity index 100% rename from src/app/mod.rs rename to crates/git-same-cli/src/app/mod.rs diff --git a/src/app/tui/mod.rs b/crates/git-same-cli/src/app/tui/mod.rs similarity index 100% rename from src/app/tui/mod.rs rename to crates/git-same-cli/src/app/tui/mod.rs diff --git a/src/banner.rs b/crates/git-same-cli/src/banner.rs similarity index 100% rename from src/banner.rs rename to crates/git-same-cli/src/banner.rs diff --git a/src/banner_tests.rs b/crates/git-same-cli/src/banner_tests.rs similarity index 100% rename from src/banner_tests.rs rename to crates/git-same-cli/src/banner_tests.rs diff --git a/src/bin/gen_completions.rs b/crates/git-same-cli/src/bin/gen_completions.rs similarity index 97% rename from src/bin/gen_completions.rs rename to crates/git-same-cli/src/bin/gen_completions.rs index d091179..74776ed 100644 --- a/src/bin/gen_completions.rs +++ b/crates/git-same-cli/src/bin/gen_completions.rs @@ -7,7 +7,7 @@ use clap::CommandFactory; use clap_complete::{generate, Shell}; -use git_same::cli::Cli; +use git_same_cli::cli::Cli; use std::{env, io, process}; fn main() { diff --git a/src/bin/gen_manpage.rs b/crates/git-same-cli/src/bin/gen_manpage.rs similarity index 94% rename from src/bin/gen_manpage.rs rename to crates/git-same-cli/src/bin/gen_manpage.rs index 22cd6e7..d867d79 100644 --- a/src/bin/gen_manpage.rs +++ b/crates/git-same-cli/src/bin/gen_manpage.rs @@ -5,7 +5,7 @@ use clap::CommandFactory; use clap_mangen::Man; -use git_same::cli::Cli; +use git_same_cli::cli::Cli; use std::{io, process}; fn main() { diff --git a/src/cli.rs b/crates/git-same-cli/src/cli.rs similarity index 100% rename from src/cli.rs rename to crates/git-same-cli/src/cli.rs diff --git a/src/cli_tests.rs b/crates/git-same-cli/src/cli_tests.rs similarity index 100% rename from src/cli_tests.rs rename to crates/git-same-cli/src/cli_tests.rs diff --git a/src/commands/daemon.rs b/crates/git-same-cli/src/commands/daemon.rs similarity index 93% rename from src/commands/daemon.rs rename to crates/git-same-cli/src/commands/daemon.rs index a05aa8c..4967302 100644 --- a/src/commands/daemon.rs +++ b/crates/git-same-cli/src/commands/daemon.rs @@ -4,18 +4,18 @@ //! computes Finder badge status, and writes the status JSON file. //! Listens on a Unix socket for refresh requests from the Finder extension. //! -//! All scanning logic lives in `crate::api::RepoScanService`. This module +//! All scanning logic lives in `git_same_core::api::RepoScanService`. This module //! is just the CLI surface (start/stop/status) plus the daemon loop and //! socket handler that drive the service. -use crate::api::{AmbientUpgradeCache, OwnerTypeCache, RepoScanService}; use crate::cli::DaemonArgs; -use crate::config::Config; -use crate::errors::Result; -use crate::git::ShellGit; -use crate::ipc::{IpcConfig, StatusFileWriter}; -use crate::output::Output; -use crate::types::OwnerType; +use git_same_core::api::{AmbientUpgradeCache, OwnerTypeCache, RepoScanService}; +use git_same_core::config::Config; +use git_same_core::errors::Result; +use git_same_core::git::ShellGit; +use git_same_core::ipc::{IpcConfig, StatusFileWriter}; +use git_same_core::output::Output; +use git_same_core::types::OwnerType; use std::path::Path; use tracing::{debug, error, info, warn}; @@ -75,7 +75,7 @@ pub async fn run(args: &DaemonArgs, config: &Config, output: &Output) -> Result< // Set up Unix socket listener #[cfg(unix)] - let socket_listener = crate::ipc::UnixSocketListener::new(ipc_config.socket_path()); + let socket_listener = git_same_core::ipc::UnixSocketListener::new(ipc_config.socket_path()); #[cfg(unix)] let tokio_listener = socket_listener.bind().await?; @@ -152,7 +152,7 @@ async fn handle_socket_connection( owner_types: Option, ambient_upgrades: Option, ) { - use crate::ipc::unix_socket::DaemonCommand; + use git_same_core::ipc::unix_socket::DaemonCommand; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; let (reader, mut writer) = stream.split(); @@ -301,15 +301,15 @@ fn spawn_owner_classifier(config: Config, cache: OwnerTypeCache) { return; } - let token = match crate::auth::gh_cli::get_token() { + let token = match git_same_core::auth::gh_cli::get_token() { Ok(t) => t, Err(e) => { warn!(error = %e, "Owner classification skipped: gh auth token unavailable"); return; } }; - let ws_provider = crate::config::WorkspaceProvider::default(); - let provider = match crate::provider::create_provider(&ws_provider, &token) { + let ws_provider = git_same_core::config::WorkspaceProvider::default(); + let provider = match git_same_core::provider::create_provider(&ws_provider, &token) { Ok(p) => p, Err(e) => { warn!(error = %e, "Owner classification skipped: provider init failed"); @@ -353,7 +353,7 @@ fn collect_owner_names(config: &Config) -> Vec { if !root.exists() { continue; } - let ws_config = match crate::config::WorkspaceStore::load(&root) { + let ws_config = match git_same_core::config::WorkspaceStore::load(&root) { Ok(ws) => ws, Err(_) => continue, }; diff --git a/src/commands/daemon_tests.rs b/crates/git-same-cli/src/commands/daemon_tests.rs similarity index 100% rename from src/commands/daemon_tests.rs rename to crates/git-same-cli/src/commands/daemon_tests.rs diff --git a/src/commands/init.rs b/crates/git-same-cli/src/commands/init.rs similarity index 93% rename from src/commands/init.rs rename to crates/git-same-cli/src/commands/init.rs index 0b99112..89ce2d7 100644 --- a/src/commands/init.rs +++ b/crates/git-same-cli/src/commands/init.rs @@ -2,11 +2,11 @@ //! //! Checks system requirements and writes the global configuration file. -use crate::checks::{self, CheckResult}; use crate::cli::InitArgs; -use crate::config::Config; -use crate::errors::{AppError, Result}; -use crate::output::Output; +use git_same_core::checks::{self, CheckResult}; +use git_same_core::config::Config; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; /// Initialize gisa configuration. pub async fn run(args: &InitArgs, output: &Output) -> Result<()> { diff --git a/src/commands/init_tests.rs b/crates/git-same-cli/src/commands/init_tests.rs similarity index 97% rename from src/commands/init_tests.rs rename to crates/git-same-cli/src/commands/init_tests.rs index 69494d6..4cae2d7 100644 --- a/src/commands/init_tests.rs +++ b/crates/git-same-cli/src/commands/init_tests.rs @@ -3,7 +3,7 @@ use crate::cli::InitArgs; use tempfile::TempDir; fn quiet_output() -> Output { - Output::new(crate::output::Verbosity::Quiet, false) + Output::new(git_same_core::output::Verbosity::Quiet, false) } #[tokio::test] diff --git a/src/commands/mod.rs b/crates/git-same-cli/src/commands/mod.rs similarity index 95% rename from src/commands/mod.rs rename to crates/git-same-cli/src/commands/mod.rs index c946ad5..b1dea5e 100644 --- a/src/commands/mod.rs +++ b/crates/git-same-cli/src/commands/mod.rs @@ -20,9 +20,9 @@ pub use status::run as run_status; pub use sync_cmd::run as run_sync_cmd; use crate::cli::Command; -use crate::config::Config; -use crate::errors::{AppError, Result}; -use crate::output::Output; +use git_same_core::config::Config; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; use std::path::Path; pub(crate) use support::{ensure_base_path, warn_if_concurrency_capped}; diff --git a/src/commands/refresh.rs b/crates/git-same-cli/src/commands/refresh.rs similarity index 88% rename from src/commands/refresh.rs rename to crates/git-same-cli/src/commands/refresh.rs index 2b11cf0..ff033d2 100644 --- a/src/commands/refresh.rs +++ b/crates/git-same-cli/src/commands/refresh.rs @@ -5,9 +5,9 @@ //! on-disk changes without waiting for the daemon's next poll. use crate::cli::RefreshArgs; -use crate::config::Config; -use crate::errors::Result; -use crate::output::Output; +use git_same_core::config::Config; +use git_same_core::errors::Result; +use git_same_core::output::Output; /// Ask the running daemon to refresh its status cache. pub async fn run(args: &RefreshArgs, _config: &Config, output: &Output) -> Result<()> { @@ -16,7 +16,7 @@ pub async fn run(args: &RefreshArgs, _config: &Config, output: &Output) -> Resul #[cfg(unix)] async fn run_impl(args: &RefreshArgs, output: &Output) -> Result<()> { - use crate::ipc::{IpcConfig, UnixSocketClient}; + use git_same_core::ipc::{IpcConfig, UnixSocketClient}; let cfg = IpcConfig::default_path()?; let client = UnixSocketClient::new(cfg.socket_path()); diff --git a/src/commands/refresh_tests.rs b/crates/git-same-cli/src/commands/refresh_tests.rs similarity index 93% rename from src/commands/refresh_tests.rs rename to crates/git-same-cli/src/commands/refresh_tests.rs index 0ebb4ef..d539bac 100644 --- a/src/commands/refresh_tests.rs +++ b/crates/git-same-cli/src/commands/refresh_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::cli::RefreshArgs; -use crate::output::{Output, Verbosity}; +use git_same_core::output::{Output, Verbosity}; #[tokio::test] async fn refresh_with_no_daemon_returns_error_on_unix() { diff --git a/src/commands/reset.rs b/crates/git-same-cli/src/commands/reset.rs similarity index 96% rename from src/commands/reset.rs rename to crates/git-same-cli/src/commands/reset.rs index efdce01..bb9ac79 100644 --- a/src/commands/reset.rs +++ b/crates/git-same-cli/src/commands/reset.rs @@ -4,10 +4,10 @@ //! Supports interactive scope selection or `--force` for scripting. use crate::cli::ResetArgs; -use crate::config::{Config, WorkspaceConfig, WorkspaceManager}; -use crate::errors::{AppError, Result}; -use crate::output::Output; use chrono::{DateTime, Utc}; +use git_same_core::config::{Config, WorkspaceConfig, WorkspaceManager}; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; use std::io::{self, BufRead, Write}; use std::path::PathBuf; @@ -78,7 +78,7 @@ pub async fn run(args: &ResetArgs, output: &Output) -> Result<()> { #[cfg(unix)] async fn nudge_daemon_refresh() { - use crate::ipc::{IpcConfig, UnixSocketClient}; + use git_same_core::ipc::{IpcConfig, UnixSocketClient}; let Ok(cfg) = IpcConfig::default_path() else { return; }; @@ -170,7 +170,7 @@ fn display_detailed_targets(scope: &ResetScope, target: &ResetTarget, output: &O /// Display detail for a single workspace. fn display_workspace_detail(ws: &WorkspaceDetail, output: &Output) { - let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let path_display = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); output.info(&format!(" Workspace at {}:", path_display)); if ws.orgs.is_empty() { @@ -255,7 +255,7 @@ fn execute_reset(scope: &ResetScope, target: &ResetTarget, output: &Output) -> R } fn remove_workspace_dir(ws: &WorkspaceDetail, output: &Output) -> bool { - let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let path_display = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); match std::fs::remove_dir_all(&ws.dot_dir) { Ok(()) => { // Also unregister from global config @@ -354,7 +354,7 @@ fn prompt_scope(target: &ResetTarget) -> Result { fn prompt_workspace(workspaces: &[WorkspaceDetail]) -> Result { eprintln!("\nSelect a workspace to delete:"); for (i, ws) in workspaces.iter().enumerate() { - let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let path_display = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let orgs = if ws.orgs.is_empty() { "all orgs".to_string() } else { diff --git a/src/commands/reset_tests.rs b/crates/git-same-cli/src/commands/reset_tests.rs similarity index 92% rename from src/commands/reset_tests.rs rename to crates/git-same-cli/src/commands/reset_tests.rs index a789aeb..238d380 100644 --- a/src/commands/reset_tests.rs +++ b/crates/git-same-cli/src/commands/reset_tests.rs @@ -76,7 +76,7 @@ fn test_display_workspace_detail_no_panic() { dot_dir: PathBuf::from("/tmp/test/.git-same"), cache_size: Some(12345), }; - let output = Output::new(crate::output::Verbosity::Quiet, false); + let output = Output::new(git_same_core::output::Verbosity::Quiet, false); display_workspace_detail(&ws, &output); } @@ -93,7 +93,7 @@ fn test_display_detailed_targets_everything() { cache_size: None, }], }; - let output = Output::new(crate::output::Verbosity::Quiet, false); + let output = Output::new(git_same_core::output::Verbosity::Quiet, false); display_detailed_targets(&ResetScope::Everything, &target, &output); } @@ -104,6 +104,6 @@ fn test_display_detailed_targets_config_only() { config_file: Some(PathBuf::from("/tmp/test/config.toml")), workspaces: Vec::new(), }; - let output = Output::new(crate::output::Verbosity::Quiet, false); + let output = Output::new(git_same_core::output::Verbosity::Quiet, false); display_detailed_targets(&ResetScope::ConfigOnly, &target, &output); } diff --git a/src/commands/scan.rs b/crates/git-same-cli/src/commands/scan.rs similarity index 96% rename from src/commands/scan.rs rename to crates/git-same-cli/src/commands/scan.rs index 62738ba..8ed9107 100644 --- a/src/commands/scan.rs +++ b/crates/git-same-cli/src/commands/scan.rs @@ -1,9 +1,9 @@ //! Scan command — find unregistered .git-same/ workspace folders. use crate::cli::ScanArgs; -use crate::config::{Config, WorkspaceStore}; -use crate::errors::{AppError, Result}; -use crate::output::Output; +use git_same_core::config::{Config, WorkspaceStore}; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -54,7 +54,7 @@ pub fn run(args: &ScanArgs, config_path: Option<&Path>, output: &Output) -> Resu let mut register_failures = Vec::new(); for ws_root in &found { let is_registered = registered.contains(ws_root); - let tilde = crate::config::workspace::tilde_collapse_path(ws_root); + let tilde = git_same_core::config::workspace::tilde_collapse_path(ws_root); if is_registered { output.plain(&format!(" [registered] {}", tilde)); } else { diff --git a/src/commands/scan_tests.rs b/crates/git-same-cli/src/commands/scan_tests.rs similarity index 92% rename from src/commands/scan_tests.rs rename to crates/git-same-cli/src/commands/scan_tests.rs index e7eab71..f2ea87e 100644 --- a/src/commands/scan_tests.rs +++ b/crates/git-same-cli/src/commands/scan_tests.rs @@ -87,17 +87,21 @@ fn run_register_with_custom_config_path_updates_registry() { .unwrap(); let custom_config_path = temp.path().join("custom-config.toml"); - std::fs::write(&custom_config_path, crate::config::Config::default_toml()).unwrap(); + std::fs::write( + &custom_config_path, + git_same_core::config::Config::default_toml(), + ) + .unwrap(); let args = crate::cli::ScanArgs { path: Some(scan_root), depth: 5, register: true, }; - let output = crate::output::Output::quiet(); + let output = git_same_core::output::Output::quiet(); run(&args, Some(&custom_config_path), &output).unwrap(); - let cfg = crate::config::Config::load_from(&custom_config_path).unwrap(); + let cfg = git_same_core::config::Config::load_from(&custom_config_path).unwrap(); assert_eq!(cfg.workspaces.len(), 1); let expected_suffix = std::path::Path::new("scan-root") .join("team") @@ -130,7 +134,7 @@ fn run_returns_error_when_custom_config_is_invalid() { depth: 5, register: false, }; - let output = crate::output::Output::quiet(); + let output = git_same_core::output::Output::quiet(); let err = run(&args, Some(&invalid_config_path), &output).unwrap_err(); assert!(err.to_string().contains("Failed to parse config")); } diff --git a/src/commands/setup.rs b/crates/git-same-cli/src/commands/setup.rs similarity index 89% rename from src/commands/setup.rs rename to crates/git-same-cli/src/commands/setup.rs index 77d0b27..6ff995e 100644 --- a/src/commands/setup.rs +++ b/crates/git-same-cli/src/commands/setup.rs @@ -5,9 +5,9 @@ #[cfg(feature = "tui")] use crate::cli::SetupArgs; #[cfg(feature = "tui")] -use crate::errors::Result; +use git_same_core::errors::Result; #[cfg(feature = "tui")] -use crate::output::Output; +use git_same_core::output::Output; /// Run the setup wizard. #[cfg(feature = "tui")] diff --git a/src/commands/status.rs b/crates/git-same-cli/src/commands/status.rs similarity index 95% rename from src/commands/status.rs rename to crates/git-same-cli/src/commands/status.rs index 6bbcbad..84f5f25 100644 --- a/src/commands/status.rs +++ b/crates/git-same-cli/src/commands/status.rs @@ -1,11 +1,11 @@ //! Status command handler. -use crate::api::RepoScanService; use crate::cli::StatusArgs; -use crate::config::{Config, WorkspaceManager}; -use crate::errors::Result; -use crate::git::ShellGit; -use crate::output::{format_count, Output}; +use git_same_core::api::RepoScanService; +use git_same_core::config::{Config, WorkspaceManager}; +use git_same_core::errors::Result; +use git_same_core::git::ShellGit; +use git_same_core::output::{format_count, Output}; /// Show status of repositories. pub async fn run(args: &StatusArgs, config: &Config, output: &Output) -> Result<()> { diff --git a/src/commands/status_tests.rs b/crates/git-same-cli/src/commands/status_tests.rs similarity index 95% rename from src/commands/status_tests.rs rename to crates/git-same-cli/src/commands/status_tests.rs index 41a1b3f..4fa081a 100644 --- a/src/commands/status_tests.rs +++ b/crates/git-same-cli/src/commands/status_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::output::Verbosity; +use git_same_core::output::Verbosity; fn quiet_output() -> Output { Output::new(Verbosity::Quiet, false) diff --git a/src/commands/support/concurrency.rs b/crates/git-same-cli/src/commands/support/concurrency.rs similarity index 85% rename from src/commands/support/concurrency.rs rename to crates/git-same-cli/src/commands/support/concurrency.rs index 3f29541..4245ef6 100644 --- a/src/commands/support/concurrency.rs +++ b/crates/git-same-cli/src/commands/support/concurrency.rs @@ -1,5 +1,5 @@ -use crate::operations::clone::MAX_CONCURRENCY; -use crate::output::Output; +use git_same_core::operations::clone::MAX_CONCURRENCY; +use git_same_core::output::Output; /// Warn if requested concurrency exceeds the maximum. /// Returns the effective concurrency to use. diff --git a/src/commands/support/concurrency_tests.rs b/crates/git-same-cli/src/commands/support/concurrency_tests.rs similarity index 92% rename from src/commands/support/concurrency_tests.rs rename to crates/git-same-cli/src/commands/support/concurrency_tests.rs index f622572..46f5010 100644 --- a/src/commands/support/concurrency_tests.rs +++ b/crates/git-same-cli/src/commands/support/concurrency_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::output::{Output, Verbosity}; +use git_same_core::output::{Output, Verbosity}; fn quiet_output() -> Output { Output::new(Verbosity::Quiet, false) diff --git a/src/commands/support/mod.rs b/crates/git-same-cli/src/commands/support/mod.rs similarity index 100% rename from src/commands/support/mod.rs rename to crates/git-same-cli/src/commands/support/mod.rs diff --git a/src/commands/support/workspace.rs b/crates/git-same-cli/src/commands/support/workspace.rs similarity index 87% rename from src/commands/support/workspace.rs rename to crates/git-same-cli/src/commands/support/workspace.rs index e0b34d4..b096e92 100644 --- a/src/commands/support/workspace.rs +++ b/crates/git-same-cli/src/commands/support/workspace.rs @@ -1,6 +1,6 @@ -use crate::config::WorkspaceConfig; -use crate::errors::{AppError, Result}; -use crate::output::Output; +use git_same_core::config::WorkspaceConfig; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; /// Ensure the workspace root path exists. /// diff --git a/src/commands/support/workspace_tests.rs b/crates/git-same-cli/src/commands/support/workspace_tests.rs similarity index 95% rename from src/commands/support/workspace_tests.rs rename to crates/git-same-cli/src/commands/support/workspace_tests.rs index 19ab951..73f1cc0 100644 --- a/src/commands/support/workspace_tests.rs +++ b/crates/git-same-cli/src/commands/support/workspace_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::output::{Output, Verbosity}; +use git_same_core::output::{Output, Verbosity}; #[test] fn ensure_base_path_is_noop_when_path_exists() { diff --git a/src/commands/sync_cmd.rs b/crates/git-same-cli/src/commands/sync_cmd.rs similarity index 94% rename from src/commands/sync_cmd.rs rename to crates/git-same-cli/src/commands/sync_cmd.rs index eac32ee..7ac7fd2 100644 --- a/src/commands/sync_cmd.rs +++ b/crates/git-same-cli/src/commands/sync_cmd.rs @@ -4,14 +4,14 @@ use super::warn_if_concurrency_capped; use crate::cli::SyncCmdArgs; -use crate::config::{Config, WorkspaceManager}; -use crate::errors::Result; -use crate::operations::clone::CloneProgress; -use crate::operations::sync::{SyncMode, SyncProgress}; -use crate::output::{ +use git_same_core::config::{Config, WorkspaceManager}; +use git_same_core::errors::Result; +use git_same_core::operations::clone::CloneProgress; +use git_same_core::operations::sync::{SyncMode, SyncProgress}; +use git_same_core::output::{ format_count, CloneProgressBar, DiscoveryProgressBar, Output, SyncProgressBar, Verbosity, }; -use crate::workflows::sync_workspace::{ +use git_same_core::workflows::sync_workspace::{ execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest, }; use std::sync::Arc; @@ -178,7 +178,7 @@ pub async fn run(args: &SyncCmdArgs, config: &Config, output: &Output) -> Result #[cfg(unix)] async fn nudge_daemon_refresh() { - use crate::ipc::{IpcConfig, UnixSocketClient}; + use git_same_core::ipc::{IpcConfig, UnixSocketClient}; let Ok(cfg) = IpcConfig::default_path() else { return; }; diff --git a/src/commands/sync_cmd_tests.rs b/crates/git-same-cli/src/commands/sync_cmd_tests.rs similarity index 98% rename from src/commands/sync_cmd_tests.rs rename to crates/git-same-cli/src/commands/sync_cmd_tests.rs index 9ad29e6..f676977 100644 --- a/src/commands/sync_cmd_tests.rs +++ b/crates/git-same-cli/src/commands/sync_cmd_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::output::{Output, Verbosity}; +use git_same_core::output::{Output, Verbosity}; use tokio::sync::Mutex; static HOME_LOCK: Mutex<()> = Mutex::const_new(()); diff --git a/src/commands/workspace.rs b/crates/git-same-cli/src/commands/workspace.rs similarity index 90% rename from src/commands/workspace.rs rename to crates/git-same-cli/src/commands/workspace.rs index c9a3cc9..7841766 100644 --- a/src/commands/workspace.rs +++ b/crates/git-same-cli/src/commands/workspace.rs @@ -1,9 +1,9 @@ //! Workspace management command handler. use crate::cli::{WorkspaceArgs, WorkspaceCommand}; -use crate::config::{Config, WorkspaceManager}; -use crate::errors::Result; -use crate::output::Output; +use git_same_core::config::{Config, WorkspaceManager}; +use git_same_core::errors::Result; +use git_same_core::output::Output; /// Run the workspace command. pub fn run(args: &WorkspaceArgs, config: &Config, output: &Output) -> Result<()> { @@ -32,7 +32,7 @@ fn list(config: &Config, output: &Output) -> Result<()> { let default_path = config.default_workspace.as_deref().unwrap_or(""); for ws in &workspaces { - let ws_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let ws_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let marker = if ws_path == default_path { "*" } else { " " }; let last_synced = ws.last_synced.as_deref().unwrap_or("never"); let org_info = if ws.orgs.is_empty() { @@ -79,7 +79,7 @@ fn show_default(config: &Config, output: &Output) -> Result<()> { fn set_default(selector: &str, config: &Config, output: &Output) -> Result<()> { let ws = WorkspaceManager::resolve(Some(selector), config)?; - let tilde_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let tilde_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); Config::save_default_workspace(Some(&tilde_path))?; output.success(&format!( "Default workspace set to '{}'", diff --git a/src/commands/workspace_tests.rs b/crates/git-same-cli/src/commands/workspace_tests.rs similarity index 95% rename from src/commands/workspace_tests.rs rename to crates/git-same-cli/src/commands/workspace_tests.rs index 024feaf..c4a74ee 100644 --- a/src/commands/workspace_tests.rs +++ b/crates/git-same-cli/src/commands/workspace_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::output::Verbosity; +use git_same_core::output::Verbosity; fn quiet_output() -> Output { Output::new(Verbosity::Quiet, false) diff --git a/crates/git-same-cli/src/lib.rs b/crates/git-same-cli/src/lib.rs new file mode 100644 index 0000000..71569b3 --- /dev/null +++ b/crates/git-same-cli/src/lib.rs @@ -0,0 +1,14 @@ +//! # git-same-cli — git-same CLI + TUI +//! +//! Library scaffolding for the `git-same` binary plus the release-tools +//! helpers `gen-completions` and `gen-manpage`. Implementation detail of +//! the binary; engine logic lives in `git-same-core`. + +pub mod app; +pub mod banner; +pub mod cli; +pub mod commands; +#[cfg(feature = "tui")] +pub mod setup; +#[cfg(feature = "tui")] +pub mod tui; diff --git a/src/main.rs b/crates/git-same-cli/src/main.rs similarity index 91% rename from src/main.rs rename to crates/git-same-cli/src/main.rs index 612b8b3..83a1c64 100644 --- a/src/main.rs +++ b/crates/git-same-cli/src/main.rs @@ -2,8 +2,8 @@ //! //! Main entry point for the git-same CLI application. -use git_same::app::cli::{run_command, Cli}; -use git_same::output::{Output, Verbosity}; +use git_same_cli::app::cli::{run_command, Cli}; +use git_same_core::output::{Output, Verbosity}; use std::process::ExitCode; use tracing::debug; @@ -11,8 +11,8 @@ use tracing::debug; /// /// Examples: /// - `GISA_LOG=debug` - Enable debug logging for all modules -/// - `GISA_LOG=git_same=debug` - Enable debug logging for git-same only -/// - `GISA_LOG=git_same::auth=trace` - Enable trace logging for auth module +/// - `GISA_LOG=git_same_core=debug` - Enable debug logging for the engine +/// - `GISA_LOG=git_same_core::auth=trace` - Enable trace logging for auth module /// - `GISA_LOG=warn` - Only show warnings and errors fn init_logging() { use tracing_subscriber::{fmt, prelude::*, EnvFilter}; @@ -57,7 +57,7 @@ async fn main() -> ExitCode { // No subcommand — launch TUI #[cfg(feature = "tui")] { - use git_same::config::Config; + use git_same_core::config::Config; // Auto-create default config if it doesn't exist let mut config_was_created = false; @@ -100,7 +100,7 @@ async fn main() -> ExitCode { match config { Ok(config) => { - match git_same::app::tui::run_tui(config, config_was_created).await { + match git_same_cli::app::tui::run_tui(config, config_was_created).await { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("TUI error: {}", e); diff --git a/src/main_tests.rs b/crates/git-same-cli/src/main_tests.rs similarity index 94% rename from src/main_tests.rs rename to crates/git-same-cli/src/main_tests.rs index 42f0134..c6f0b48 100644 --- a/src/main_tests.rs +++ b/crates/git-same-cli/src/main_tests.rs @@ -1,6 +1,6 @@ use super::*; use clap::Parser; -use git_same::cli::Command; +use git_same_cli::cli::Command; #[test] fn main_cli_parses_sync_subcommand() { diff --git a/src/setup/handler.rs b/crates/git-same-cli/src/setup/handler.rs similarity index 98% rename from src/setup/handler.rs rename to crates/git-same-cli/src/setup/handler.rs index a94bfc6..5206787 100644 --- a/src/setup/handler.rs +++ b/crates/git-same-cli/src/setup/handler.rs @@ -3,10 +3,10 @@ use super::state::{ tilde_collapse, AuthStatus, OrgEntry, PathBrowseEntry, SetupOutcome, SetupState, SetupStep, }; -use crate::auth::{get_auth_for_provider, gh_cli}; -use crate::config::{WorkspaceConfig, WorkspaceManager}; -use crate::provider::create_provider; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::auth::{get_auth_for_provider, gh_cli}; +use git_same_core::config::{WorkspaceConfig, WorkspaceManager}; +use git_same_core::provider::create_provider; /// Handle a key event in the setup wizard. /// @@ -640,7 +640,7 @@ async fn do_discover_orgs(state: &mut SetupState) { } pub(crate) async fn discover_org_entries( - ws_provider: crate::config::WorkspaceProvider, + ws_provider: git_same_core::config::WorkspaceProvider, token: String, ) -> Result, String> { match create_provider(&ws_provider, &token) { @@ -700,11 +700,11 @@ fn handle_complete(state: &mut SetupState, key: KeyEvent) { } } -fn save_workspace(state: &SetupState) -> Result<(), crate::errors::AppError> { +fn save_workspace(state: &SetupState) -> Result<(), git_same_core::errors::AppError> { let expanded = shellexpand::tilde(&state.base_path); let root = std::path::Path::new(expanded.as_ref()); std::fs::create_dir_all(root).map_err(|e| { - crate::errors::AppError::config(format!( + git_same_core::errors::AppError::config(format!( "Failed to create workspace directory '{}': {}", root.display(), e diff --git a/src/setup/handler_tests.rs b/crates/git-same-cli/src/setup/handler_tests.rs similarity index 99% rename from src/setup/handler_tests.rs rename to crates/git-same-cli/src/setup/handler_tests.rs index cca287c..a695071 100644 --- a/src/setup/handler_tests.rs +++ b/crates/git-same-cli/src/setup/handler_tests.rs @@ -445,7 +445,7 @@ async fn enter_while_checks_loading_does_not_advance_requirements_step() { state.step = SetupStep::Requirements; state.checks_loading = true; // Plant a passing result to confirm loading flag is the only blocker. - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "git".to_string(), passed: true, message: "git 2.40".to_string(), @@ -485,7 +485,7 @@ async fn enter_when_critical_check_failed_does_not_advance() { let mut state = SetupState::new("~/Git-Same/GitHub"); state.step = SetupStep::Requirements; state.checks_loading = false; - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "git".to_string(), passed: false, message: "not found".to_string(), @@ -508,7 +508,7 @@ async fn enter_when_requirements_passed_and_not_loading_advances_step() { let mut state = SetupState::new("~/Git-Same/GitHub"); state.step = SetupStep::Requirements; state.checks_loading = false; - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "git".to_string(), passed: true, message: "git 2.40".to_string(), diff --git a/src/setup/mod.rs b/crates/git-same-cli/src/setup/mod.rs similarity index 95% rename from src/setup/mod.rs rename to crates/git-same-cli/src/setup/mod.rs index 3fcbbe2..71b9296 100644 --- a/src/setup/mod.rs +++ b/crates/git-same-cli/src/setup/mod.rs @@ -9,12 +9,12 @@ pub mod screens; pub mod state; pub mod ui; -use crate::errors::Result; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture, Event as CtEvent}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use git_same_core::errors::Result; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use state::{SetupOutcome, SetupState, SetupStep}; @@ -140,7 +140,7 @@ pub(crate) fn maybe_start_requirements_checks(state: &mut SetupState) -> bool { state.checks_triggered = true; state.checks_loading = true; - state.config_path_display = crate::config::Config::default_path() + state.config_path_display = git_same_core::config::Config::default_path() .ok() .map(|p| p.display().to_string()); true @@ -148,14 +148,14 @@ pub(crate) fn maybe_start_requirements_checks(state: &mut SetupState) -> bool { pub(crate) fn apply_requirements_check_results( state: &mut SetupState, - results: Vec, + results: Vec, ) { state.check_results = results; state.checks_loading = false; } pub(crate) async fn run_requirements_checks(state: &mut SetupState) { - let results = crate::checks::check_requirements().await; + let results = git_same_core::checks::check_requirements().await; apply_requirements_check_results(state, results); } diff --git a/src/setup/mod_tests.rs b/crates/git-same-cli/src/setup/mod_tests.rs similarity index 92% rename from src/setup/mod_tests.rs rename to crates/git-same-cli/src/setup/mod_tests.rs index 87d04f5..706ff97 100644 --- a/src/setup/mod_tests.rs +++ b/crates/git-same-cli/src/setup/mod_tests.rs @@ -1,7 +1,7 @@ use super::*; -fn sample_check(name: &str, passed: bool, critical: bool) -> crate::checks::CheckResult { - crate::checks::CheckResult { +fn sample_check(name: &str, passed: bool, critical: bool) -> git_same_core::checks::CheckResult { + git_same_core::checks::CheckResult { name: name.to_string(), passed, message: "ok".to_string(), @@ -22,7 +22,7 @@ fn maybe_start_requirements_checks_sets_expected_state() { assert!(state.checks_loading); assert_eq!( state.config_path_display, - crate::config::Config::default_path() + git_same_core::config::Config::default_path() .ok() .map(|p| p.display().to_string()) ); diff --git a/src/setup/screens/auth.rs b/crates/git-same-cli/src/setup/screens/auth.rs similarity index 100% rename from src/setup/screens/auth.rs rename to crates/git-same-cli/src/setup/screens/auth.rs diff --git a/src/setup/screens/auth_tests.rs b/crates/git-same-cli/src/setup/screens/auth_tests.rs similarity index 100% rename from src/setup/screens/auth_tests.rs rename to crates/git-same-cli/src/setup/screens/auth_tests.rs diff --git a/src/setup/screens/complete.rs b/crates/git-same-cli/src/setup/screens/complete.rs similarity index 100% rename from src/setup/screens/complete.rs rename to crates/git-same-cli/src/setup/screens/complete.rs diff --git a/src/setup/screens/complete_tests.rs b/crates/git-same-cli/src/setup/screens/complete_tests.rs similarity index 100% rename from src/setup/screens/complete_tests.rs rename to crates/git-same-cli/src/setup/screens/complete_tests.rs diff --git a/src/setup/screens/confirm.rs b/crates/git-same-cli/src/setup/screens/confirm.rs similarity index 100% rename from src/setup/screens/confirm.rs rename to crates/git-same-cli/src/setup/screens/confirm.rs diff --git a/src/setup/screens/confirm_tests.rs b/crates/git-same-cli/src/setup/screens/confirm_tests.rs similarity index 100% rename from src/setup/screens/confirm_tests.rs rename to crates/git-same-cli/src/setup/screens/confirm_tests.rs diff --git a/src/setup/screens/mod.rs b/crates/git-same-cli/src/setup/screens/mod.rs similarity index 100% rename from src/setup/screens/mod.rs rename to crates/git-same-cli/src/setup/screens/mod.rs diff --git a/src/setup/screens/mod_tests.rs b/crates/git-same-cli/src/setup/screens/mod_tests.rs similarity index 100% rename from src/setup/screens/mod_tests.rs rename to crates/git-same-cli/src/setup/screens/mod_tests.rs diff --git a/src/setup/screens/orgs.rs b/crates/git-same-cli/src/setup/screens/orgs.rs similarity index 100% rename from src/setup/screens/orgs.rs rename to crates/git-same-cli/src/setup/screens/orgs.rs diff --git a/src/setup/screens/orgs_tests.rs b/crates/git-same-cli/src/setup/screens/orgs_tests.rs similarity index 100% rename from src/setup/screens/orgs_tests.rs rename to crates/git-same-cli/src/setup/screens/orgs_tests.rs diff --git a/src/setup/screens/path.rs b/crates/git-same-cli/src/setup/screens/path.rs similarity index 100% rename from src/setup/screens/path.rs rename to crates/git-same-cli/src/setup/screens/path.rs diff --git a/src/setup/screens/path_tests.rs b/crates/git-same-cli/src/setup/screens/path_tests.rs similarity index 100% rename from src/setup/screens/path_tests.rs rename to crates/git-same-cli/src/setup/screens/path_tests.rs diff --git a/src/setup/screens/provider.rs b/crates/git-same-cli/src/setup/screens/provider.rs similarity index 98% rename from src/setup/screens/provider.rs rename to crates/git-same-cli/src/setup/screens/provider.rs index 393c7c4..1447e09 100644 --- a/src/setup/screens/provider.rs +++ b/crates/git-same-cli/src/setup/screens/provider.rs @@ -1,7 +1,7 @@ //! Step 1: Provider selection screen with descriptions. use crate::setup::state::SetupState; -use crate::types::ProviderKind; +use git_same_core::types::ProviderKind; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; diff --git a/src/setup/screens/provider_tests.rs b/crates/git-same-cli/src/setup/screens/provider_tests.rs similarity index 97% rename from src/setup/screens/provider_tests.rs rename to crates/git-same-cli/src/setup/screens/provider_tests.rs index 04eb753..f04b654 100644 --- a/src/setup/screens/provider_tests.rs +++ b/crates/git-same-cli/src/setup/screens/provider_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::setup::state::SetupState; -use crate::types::ProviderKind; +use git_same_core::types::ProviderKind; use ratatui::backend::TestBackend; use ratatui::Terminal; diff --git a/src/setup/screens/requirements.rs b/crates/git-same-cli/src/setup/screens/requirements.rs similarity index 100% rename from src/setup/screens/requirements.rs rename to crates/git-same-cli/src/setup/screens/requirements.rs diff --git a/src/setup/screens/requirements_tests.rs b/crates/git-same-cli/src/setup/screens/requirements_tests.rs similarity index 95% rename from src/setup/screens/requirements_tests.rs rename to crates/git-same-cli/src/setup/screens/requirements_tests.rs index 25bf7f2..c36e1c4 100644 --- a/src/setup/screens/requirements_tests.rs +++ b/crates/git-same-cli/src/setup/screens/requirements_tests.rs @@ -51,7 +51,7 @@ fn render_loading_shows_spinner() { fn render_passed_checks_shows_continue_hint() { let mut state = SetupState::new("~/Git-Same/GitHub"); state.checks_loading = false; - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "Git".to_string(), passed: true, message: "git 2.43.0".to_string(), @@ -66,7 +66,7 @@ fn render_passed_checks_shows_continue_hint() { fn render_failed_critical_shows_fix_hint() { let mut state = SetupState::new("~/Git-Same/GitHub"); state.checks_loading = false; - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "Git".to_string(), passed: false, message: "not found".to_string(), diff --git a/src/setup/state.rs b/crates/git-same-cli/src/setup/state.rs similarity index 98% rename from src/setup/state.rs rename to crates/git-same-cli/src/setup/state.rs index 24c18e0..16d94a9 100644 --- a/src/setup/state.rs +++ b/crates/git-same-cli/src/setup/state.rs @@ -1,7 +1,7 @@ //! Setup wizard state (the "Model" in Elm architecture). -use crate::config::WorkspaceProvider; -use crate::types::ProviderKind; +use git_same_core::config::WorkspaceProvider; +use git_same_core::types::ProviderKind; /// Which step of the wizard is active. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -74,7 +74,7 @@ pub struct SetupState { pub outcome: Option, // Step 1: Requirements check - pub check_results: Vec, + pub check_results: Vec, pub checks_loading: bool, pub checks_triggered: bool, pub config_path_display: Option, diff --git a/src/setup/state_tests.rs b/crates/git-same-cli/src/setup/state_tests.rs similarity index 97% rename from src/setup/state_tests.rs rename to crates/git-same-cli/src/setup/state_tests.rs index 0a15f9e..2865189 100644 --- a/src/setup/state_tests.rs +++ b/crates/git-same-cli/src/setup/state_tests.rs @@ -144,14 +144,14 @@ fn test_cancel_from_requirements() { fn test_requirements_passed_all_critical_pass() { let mut state = SetupState::new("~/Git-Same/GitHub"); state.check_results = vec![ - crate::checks::CheckResult { + git_same_core::checks::CheckResult { name: "Git".to_string(), passed: true, message: "ok".to_string(), suggestion: None, critical: true, }, - crate::checks::CheckResult { + git_same_core::checks::CheckResult { name: "SSH".to_string(), passed: false, message: "not found".to_string(), @@ -165,7 +165,7 @@ fn test_requirements_passed_all_critical_pass() { #[test] fn test_requirements_passed_critical_fail() { let mut state = SetupState::new("~/Git-Same/GitHub"); - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "Git".to_string(), passed: false, message: "not found".to_string(), diff --git a/src/setup/ui.rs b/crates/git-same-cli/src/setup/ui.rs similarity index 100% rename from src/setup/ui.rs rename to crates/git-same-cli/src/setup/ui.rs diff --git a/src/setup/ui_tests.rs b/crates/git-same-cli/src/setup/ui_tests.rs similarity index 100% rename from src/setup/ui_tests.rs rename to crates/git-same-cli/src/setup/ui_tests.rs diff --git a/src/tui/app.rs b/crates/git-same-cli/src/tui/app.rs similarity index 96% rename from src/tui/app.rs rename to crates/git-same-cli/src/tui/app.rs index 18b2d12..fd2bd3d 100644 --- a/src/tui/app.rs +++ b/crates/git-same-cli/src/tui/app.rs @@ -1,8 +1,8 @@ //! TUI application state (the "Model" in Elm architecture). -use crate::config::{Config, WorkspaceConfig}; use crate::setup::state::{self, SetupState}; -use crate::types::{OpSummary, OwnedRepo, RepoEntry, SyncHistoryEntry}; +use git_same_core::config::{Config, WorkspaceConfig}; +use git_same_core::types::{OpSummary, OwnedRepo, RepoEntry, SyncHistoryEntry}; use ratatui::widgets::TableState; use std::collections::HashMap; use std::path::PathBuf; @@ -301,7 +301,7 @@ impl App { let sync_history = active_workspace .as_ref() .and_then(|ws| { - crate::cache::SyncHistoryManager::for_workspace(&ws.root_path) + git_same_core::cache::SyncHistoryManager::for_workspace(&ws.root_path) .and_then(|m| m.load()) .ok() }) @@ -369,9 +369,10 @@ impl App { if let Some(ws) = self.workspaces.get(index).cloned() { self.base_path = Some(ws.expanded_base_path()); // Load sync history for this workspace - self.sync_history = crate::cache::SyncHistoryManager::for_workspace(&ws.root_path) - .and_then(|m| m.load()) - .unwrap_or_default(); + self.sync_history = + git_same_core::cache::SyncHistoryManager::for_workspace(&ws.root_path) + .and_then(|m| m.load()) + .unwrap_or_default(); self.active_workspace = Some(ws); // Reset discovered data when switching workspace self.repos_by_org.clear(); diff --git a/src/tui/app_tests.rs b/crates/git-same-cli/src/tui/app_tests.rs similarity index 100% rename from src/tui/app_tests.rs rename to crates/git-same-cli/src/tui/app_tests.rs diff --git a/src/tui/backend.rs b/crates/git-same-cli/src/tui/backend.rs similarity index 96% rename from src/tui/backend.rs rename to crates/git-same-cli/src/tui/backend.rs index f71c14a..66a9081 100644 --- a/src/tui/backend.rs +++ b/crates/git-same-cli/src/tui/backend.rs @@ -6,14 +6,14 @@ use std::path::Path; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; -use crate::config::{Config, WorkspaceConfig, WorkspaceProvider}; -use crate::git::{FetchResult, GitOperations, PullResult, ShellGit}; -use crate::operations::clone::CloneProgress; -use crate::operations::sync::SyncProgress; -use crate::provider::DiscoveryProgress; -use crate::types::{OpSummary, OwnedRepo}; -use crate::workflows::status_scan::scan_workspace_status; -use crate::workflows::sync_workspace::{ +use git_same_core::config::{Config, WorkspaceConfig, WorkspaceProvider}; +use git_same_core::git::{FetchResult, GitOperations, PullResult, ShellGit}; +use git_same_core::operations::clone::CloneProgress; +use git_same_core::operations::sync::SyncProgress; +use git_same_core::provider::DiscoveryProgress; +use git_same_core::types::{OpSummary, OwnedRepo}; +use git_same_core::workflows::status_scan::scan_workspace_status; +use git_same_core::workflows::sync_workspace::{ execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest, }; diff --git a/src/tui/backend_tests.rs b/crates/git-same-cli/src/tui/backend_tests.rs similarity index 96% rename from src/tui/backend_tests.rs rename to crates/git-same-cli/src/tui/backend_tests.rs index 2677074..21008c9 100644 --- a/src/tui/backend_tests.rs +++ b/crates/git-same-cli/src/tui/backend_tests.rs @@ -1,11 +1,11 @@ use super::*; -use crate::config::Config; -use crate::git::{FetchResult, PullResult}; -use crate::operations::clone::CloneProgress; -use crate::operations::sync::SyncProgress; -use crate::provider::DiscoveryProgress; use crate::tui::event::{AppEvent, BackendMessage}; -use crate::types::{OwnedRepo, Repo}; +use git_same_core::config::Config; +use git_same_core::git::{FetchResult, PullResult}; +use git_same_core::operations::clone::CloneProgress; +use git_same_core::operations::sync::SyncProgress; +use git_same_core::provider::DiscoveryProgress; +use git_same_core::types::{OwnedRepo, Repo}; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; use tokio::time::{timeout, Duration}; diff --git a/src/tui/event.rs b/crates/git-same-cli/src/tui/event.rs similarity index 98% rename from src/tui/event.rs rename to crates/git-same-cli/src/tui/event.rs index 1a6de6f..dc293d1 100644 --- a/src/tui/event.rs +++ b/crates/git-same-cli/src/tui/event.rs @@ -6,7 +6,7 @@ use tokio::sync::mpsc; use tracing::warn; use crate::setup::state::OrgEntry; -use crate::types::{OpSummary, OwnedRepo, RepoEntry}; +use git_same_core::types::{OpSummary, OwnedRepo, RepoEntry}; use super::app::{CheckEntry, Operation}; diff --git a/src/tui/event_tests.rs b/crates/git-same-cli/src/tui/event_tests.rs similarity index 96% rename from src/tui/event_tests.rs rename to crates/git-same-cli/src/tui/event_tests.rs index 0071b5b..8fe13ec 100644 --- a/src/tui/event_tests.rs +++ b/crates/git-same-cli/src/tui/event_tests.rs @@ -1,12 +1,12 @@ use super::*; use crate::setup::state::OrgEntry; use crate::tui::app::{CheckEntry, Operation}; -use crate::types::{OpSummary, RepoEntry}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::types::{OpSummary, RepoEntry}; use std::path::PathBuf; fn sample_repo() -> OwnedRepo { - OwnedRepo::new("acme", crate::types::Repo::test("rocket", "acme")) + OwnedRepo::new("acme", git_same_core::types::Repo::test("rocket", "acme")) } fn sample_repo_entry() -> RepoEntry { diff --git a/src/tui/handler.rs b/crates/git-same-cli/src/tui/handler.rs similarity index 98% rename from src/tui/handler.rs rename to crates/git-same-cli/src/tui/handler.rs index d2df5dc..724e2b5 100644 --- a/src/tui/handler.rs +++ b/crates/git-same-cli/src/tui/handler.rs @@ -8,11 +8,11 @@ use super::app::{ }; use super::event::{AppEvent, BackendMessage}; use super::screens; -use crate::cache::SyncHistoryManager; -use crate::config::WorkspaceManager; -use crate::domain::RepoPathTemplate; use crate::setup::state::{SetupOutcome, SetupStep}; -use crate::types::SyncHistoryEntry; +use git_same_core::cache::SyncHistoryManager; +use git_same_core::config::WorkspaceManager; +use git_same_core::domain::RepoPathTemplate; +use git_same_core::types::SyncHistoryEntry; const MAX_THROUGHPUT_SAMPLES: usize = 240; const MAX_LOG_LINES: usize = 5_000; @@ -66,7 +66,7 @@ pub async fn handle_event(app: &mut App, event: AppEvent, backend_tx: &Unbounded if crate::setup::maybe_start_requirements_checks(setup) { let tx = backend_tx.clone(); tokio::spawn(async move { - let results = crate::checks::check_requirements().await; + let results = git_same_core::checks::check_requirements().await; let entries: Vec = results .into_iter() .map(|r| CheckEntry { @@ -110,7 +110,7 @@ pub async fn handle_event(app: &mut App, event: AppEvent, backend_tx: &Unbounded app.checks_loading = true; let tx = backend_tx.clone(); tokio::spawn(async move { - let results = crate::checks::check_requirements().await; + let results = git_same_core::checks::check_requirements().await; let entries: Vec = results .into_iter() .map(|r| CheckEntry { @@ -605,7 +605,7 @@ fn handle_backend_message( // Map CheckEntry back to CheckResult for setup state storage let results = entries .iter() - .map(|e| crate::checks::CheckResult { + .map(|e| git_same_core::checks::CheckResult { name: e.name.clone(), passed: e.passed, message: e.message.clone(), diff --git a/src/tui/handler_tests.rs b/crates/git-same-cli/src/tui/handler_tests.rs similarity index 98% rename from src/tui/handler_tests.rs rename to crates/git-same-cli/src/tui/handler_tests.rs index 9df93e4..369c983 100644 --- a/src/tui/handler_tests.rs +++ b/crates/git-same-cli/src/tui/handler_tests.rs @@ -1,8 +1,8 @@ use super::*; -use crate::config::{Config, WorkspaceConfig}; use crate::setup::state::{OrgEntry, SetupState, SetupStep}; use crate::tui::event::{AppEvent, BackendMessage}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::config::{Config, WorkspaceConfig}; use tokio::sync::mpsc::unbounded_channel; #[tokio::test] diff --git a/src/tui/mod.rs b/crates/git-same-cli/src/tui/mod.rs similarity index 96% rename from src/tui/mod.rs rename to crates/git-same-cli/src/tui/mod.rs index 43cbfe3..fd23e2d 100644 --- a/src/tui/mod.rs +++ b/crates/git-same-cli/src/tui/mod.rs @@ -10,13 +10,13 @@ pub mod screens; pub mod ui; pub mod widgets; -use crate::config::{Config, WorkspaceManager}; -use crate::errors::Result; use app::App; use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use git_same_core::config::{Config, WorkspaceManager}; +use git_same_core::errors::Result; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use std::io; diff --git a/src/tui/screens/dashboard.rs b/crates/git-same-cli/src/tui/screens/dashboard.rs similarity index 99% rename from src/tui/screens/dashboard.rs rename to crates/git-same-cli/src/tui/screens/dashboard.rs index 3c2cf23..7acf9e7 100644 --- a/src/tui/screens/dashboard.rs +++ b/crates/git-same-cli/src/tui/screens/dashboard.rs @@ -18,7 +18,7 @@ use tokio::sync::mpsc::UnboundedSender; use crate::banner::{render_animated_banner, render_banner}; use crate::tui::app::{App, Operation, OperationState, Screen}; use crate::tui::event::AppEvent; -use crate::types::RepoEntry; +use git_same_core::types::RepoEntry; // ── Key handler ───────────────────────────────────────────────────────────── diff --git a/src/tui/screens/dashboard_tests.rs b/crates/git-same-cli/src/tui/screens/dashboard_tests.rs similarity index 99% rename from src/tui/screens/dashboard_tests.rs rename to crates/git-same-cli/src/tui/screens/dashboard_tests.rs index b3811a3..4f5cbf0 100644 --- a/src/tui/screens/dashboard_tests.rs +++ b/crates/git-same-cli/src/tui/screens/dashboard_tests.rs @@ -1,6 +1,6 @@ use super::*; -use crate::config::{Config, WorkspaceConfig}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::config::{Config, WorkspaceConfig}; use tokio::sync::mpsc::unbounded_channel; fn build_app() -> App { diff --git a/src/tui/screens/mod.rs b/crates/git-same-cli/src/tui/screens/mod.rs similarity index 100% rename from src/tui/screens/mod.rs rename to crates/git-same-cli/src/tui/screens/mod.rs diff --git a/src/tui/screens/settings.rs b/crates/git-same-cli/src/tui/screens/settings.rs similarity index 98% rename from src/tui/screens/settings.rs rename to crates/git-same-cli/src/tui/screens/settings.rs index f26d8c3..d14dbb3 100644 --- a/src/tui/screens/settings.rs +++ b/crates/git-same-cli/src/tui/screens/settings.rs @@ -27,7 +27,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { } KeyCode::Char('c') => { // Open config directory in Finder / file manager - if let Ok(path) = crate::config::Config::default_path() { + if let Ok(path) = git_same_core::config::Config::default_path() { if let Some(parent) = path.parent() { if let Err(e) = open_directory(parent) { app.error_message = Some(format!( @@ -218,7 +218,7 @@ fn render_options_detail(app: &App, frame: &mut Frame, area: Rect) { .fg(Color::Rgb(21, 128, 61)) .add_modifier(Modifier::BOLD); - let config_path = crate::config::Config::default_path() + let config_path = git_same_core::config::Config::default_path() .ok() .and_then(|p| p.parent().map(|parent| parent.display().to_string())) .unwrap_or_else(|| "~/.config/git-same".to_string()); diff --git a/src/tui/screens/settings_tests.rs b/crates/git-same-cli/src/tui/screens/settings_tests.rs similarity index 97% rename from src/tui/screens/settings_tests.rs rename to crates/git-same-cli/src/tui/screens/settings_tests.rs index f8da0b2..2420a7e 100644 --- a/src/tui/screens/settings_tests.rs +++ b/crates/git-same-cli/src/tui/screens/settings_tests.rs @@ -1,6 +1,6 @@ use super::*; -use crate::config::{Config, WorkspaceConfig}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::config::{Config, WorkspaceConfig}; use ratatui::backend::TestBackend; use ratatui::Terminal; diff --git a/src/tui/screens/sync.rs b/crates/git-same-cli/src/tui/screens/sync.rs similarity index 100% rename from src/tui/screens/sync.rs rename to crates/git-same-cli/src/tui/screens/sync.rs diff --git a/src/tui/screens/sync_tests.rs b/crates/git-same-cli/src/tui/screens/sync_tests.rs similarity index 95% rename from src/tui/screens/sync_tests.rs rename to crates/git-same-cli/src/tui/screens/sync_tests.rs index 40a0076..ab96d4f 100644 --- a/src/tui/screens/sync_tests.rs +++ b/crates/git-same-cli/src/tui/screens/sync_tests.rs @@ -1,8 +1,8 @@ use super::*; -use crate::config::{Config, WorkspaceConfig}; use crate::tui::app::{Operation, Screen}; -use crate::types::OpSummary; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::config::{Config, WorkspaceConfig}; +use git_same_core::types::OpSummary; use tokio::sync::mpsc::unbounded_channel; fn build_app() -> App { diff --git a/src/tui/screens/workspaces.rs b/crates/git-same-cli/src/tui/screens/workspaces.rs similarity index 98% rename from src/tui/screens/workspaces.rs rename to crates/git-same-cli/src/tui/screens/workspaces.rs index 7f9638e..bc525d9 100644 --- a/src/tui/screens/workspaces.rs +++ b/crates/git-same-cli/src/tui/screens/workspaces.rs @@ -19,10 +19,10 @@ use tokio::sync::mpsc::UnboundedSender; use std::sync::atomic::{AtomicUsize, Ordering}; use crate::banner::render_banner; -use crate::config::{Config, SyncMode, WorkspaceConfig, WorkspaceManager}; use crate::setup::state::SetupState; use crate::tui::app::{App, Screen, WorkspacePane}; use crate::tui::event::{AppEvent, BackendMessage}; +use git_same_core::config::{Config, SyncMode, WorkspaceConfig, WorkspaceManager}; #[cfg(test)] static OPEN_WORKSPACE_FOLDER_CALLS: AtomicUsize = AtomicUsize::new(0); @@ -99,7 +99,7 @@ pub async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSend KeyCode::Char('d') if app.workspace_index < num_ws => { // Set default workspace if let Some(ws) = app.workspaces.get(app.workspace_index) { - let ws_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let ws_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let current_default = app.config.default_workspace.as_deref(); if current_default == Some(ws_path.as_str()) { // Already default, do nothing @@ -233,7 +233,7 @@ fn render_workspace_nav(app: &App, frame: &mut Frame, area: Rect) { .as_ref() .map(|aw| aw.root_path == ws.root_path) .unwrap_or(false); - let ws_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let ws_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let is_default = app.config.default_workspace.as_deref() == Some(ws_path.as_str()); let folder_name = ws @@ -316,7 +316,7 @@ fn render_workspace_detail(app: &App, ws: &WorkspaceConfig, frame: &mut Frame, a .fg(Color::Rgb(37, 99, 235)) .add_modifier(Modifier::BOLD); - let ws_tilde_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let ws_tilde_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let is_default = app .config diff --git a/src/tui/screens/workspaces_tests.rs b/crates/git-same-cli/src/tui/screens/workspaces_tests.rs similarity index 100% rename from src/tui/screens/workspaces_tests.rs rename to crates/git-same-cli/src/tui/screens/workspaces_tests.rs diff --git a/src/tui/ui.rs b/crates/git-same-cli/src/tui/ui.rs similarity index 100% rename from src/tui/ui.rs rename to crates/git-same-cli/src/tui/ui.rs diff --git a/src/tui/widgets/mod.rs b/crates/git-same-cli/src/tui/widgets/mod.rs similarity index 100% rename from src/tui/widgets/mod.rs rename to crates/git-same-cli/src/tui/widgets/mod.rs diff --git a/src/tui/widgets/repo_table.rs b/crates/git-same-cli/src/tui/widgets/repo_table.rs similarity index 97% rename from src/tui/widgets/repo_table.rs rename to crates/git-same-cli/src/tui/widgets/repo_table.rs index fed96e6..9dd3ba9 100644 --- a/src/tui/widgets/repo_table.rs +++ b/crates/git-same-cli/src/tui/widgets/repo_table.rs @@ -7,7 +7,7 @@ use ratatui::{ Frame, }; -use crate::types::OwnedRepo; +use git_same_core::types::OwnedRepo; /// Render a table of OwnedRepo entries. pub fn render_owned_repos( diff --git a/src/tui/widgets/repo_table_tests.rs b/crates/git-same-cli/src/tui/widgets/repo_table_tests.rs similarity index 87% rename from src/tui/widgets/repo_table_tests.rs rename to crates/git-same-cli/src/tui/widgets/repo_table_tests.rs index d77fa80..fe6b43b 100644 --- a/src/tui/widgets/repo_table_tests.rs +++ b/crates/git-same-cli/src/tui/widgets/repo_table_tests.rs @@ -26,8 +26,8 @@ fn render_output(repos: &[&OwnedRepo]) -> String { #[test] fn repo_table_renders_title_headers_and_rows() { - let public_repo = OwnedRepo::new("acme", crate::types::Repo::test("rocket", "acme")); - let mut private_repo = crate::types::Repo::test("vault", "acme"); + let public_repo = OwnedRepo::new("acme", git_same_core::types::Repo::test("rocket", "acme")); + let mut private_repo = git_same_core::types::Repo::test("vault", "acme"); private_repo.private = true; let private_repo = OwnedRepo::new("acme", private_repo); diff --git a/src/tui/widgets/status_bar.rs b/crates/git-same-cli/src/tui/widgets/status_bar.rs similarity index 100% rename from src/tui/widgets/status_bar.rs rename to crates/git-same-cli/src/tui/widgets/status_bar.rs diff --git a/tests/integration_test.rs b/crates/git-same-cli/tests/integration_test.rs similarity index 98% rename from tests/integration_test.rs rename to crates/git-same-cli/tests/integration_test.rs index 45deb30..c148d96 100644 --- a/tests/integration_test.rs +++ b/crates/git-same-cli/tests/integration_test.rs @@ -6,15 +6,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; fn git_same_binary() -> PathBuf { - std::env::var_os("CARGO_BIN_EXE_git_same") - .map(PathBuf::from) - .unwrap_or_else(|| { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("target/debug/git-same"); - #[cfg(target_os = "windows")] - path.set_extension("exe"); - path - }) + PathBuf::from(env!("CARGO_BIN_EXE_git-same")) } fn command_with_temp_env(home: &Path) -> Command { diff --git a/crates/git-same-core/Cargo.toml b/crates/git-same-core/Cargo.toml new file mode 100644 index 0000000..7558a77 --- /dev/null +++ b/crates/git-same-core/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "git-same-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["git", "github", "library"] +categories = ["development-tools"] +description = "Core engine for git-same: discovery, clone, sync, IPC, status." + +[features] +# Exposes test helpers (e.g. Repo::test) for downstream crates' integration tests. +test-utils = [] + +[dependencies] +tokio = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +indicatif = { workspace = true } +console = { workspace = true } +directories = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +shellexpand = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } +futures = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +tokio-test = { workspace = true } +mockito = { workspace = true } +tempfile = { workspace = true } diff --git a/src/api/ambient_upgrade_cache.rs b/crates/git-same-core/src/api/ambient_upgrade_cache.rs similarity index 100% rename from src/api/ambient_upgrade_cache.rs rename to crates/git-same-core/src/api/ambient_upgrade_cache.rs diff --git a/src/api/ambient_upgrade_cache_tests.rs b/crates/git-same-core/src/api/ambient_upgrade_cache_tests.rs similarity index 100% rename from src/api/ambient_upgrade_cache_tests.rs rename to crates/git-same-core/src/api/ambient_upgrade_cache_tests.rs diff --git a/src/api/mod.rs b/crates/git-same-core/src/api/mod.rs similarity index 100% rename from src/api/mod.rs rename to crates/git-same-core/src/api/mod.rs diff --git a/src/api/owner_type_cache.rs b/crates/git-same-core/src/api/owner_type_cache.rs similarity index 100% rename from src/api/owner_type_cache.rs rename to crates/git-same-core/src/api/owner_type_cache.rs diff --git a/src/api/owner_type_cache_tests.rs b/crates/git-same-core/src/api/owner_type_cache_tests.rs similarity index 100% rename from src/api/owner_type_cache_tests.rs rename to crates/git-same-core/src/api/owner_type_cache_tests.rs diff --git a/src/api/service.rs b/crates/git-same-core/src/api/service.rs similarity index 100% rename from src/api/service.rs rename to crates/git-same-core/src/api/service.rs diff --git a/src/api/service_tests.rs b/crates/git-same-core/src/api/service_tests.rs similarity index 100% rename from src/api/service_tests.rs rename to crates/git-same-core/src/api/service_tests.rs diff --git a/src/auth/gh_cli.rs b/crates/git-same-core/src/auth/gh_cli.rs similarity index 100% rename from src/auth/gh_cli.rs rename to crates/git-same-core/src/auth/gh_cli.rs diff --git a/src/auth/gh_cli_tests.rs b/crates/git-same-core/src/auth/gh_cli_tests.rs similarity index 100% rename from src/auth/gh_cli_tests.rs rename to crates/git-same-core/src/auth/gh_cli_tests.rs diff --git a/src/auth/mod.rs b/crates/git-same-core/src/auth/mod.rs similarity index 100% rename from src/auth/mod.rs rename to crates/git-same-core/src/auth/mod.rs diff --git a/src/auth/mod_tests.rs b/crates/git-same-core/src/auth/mod_tests.rs similarity index 100% rename from src/auth/mod_tests.rs rename to crates/git-same-core/src/auth/mod_tests.rs diff --git a/src/auth/process.rs b/crates/git-same-core/src/auth/process.rs similarity index 100% rename from src/auth/process.rs rename to crates/git-same-core/src/auth/process.rs diff --git a/src/auth/process_tests.rs b/crates/git-same-core/src/auth/process_tests.rs similarity index 100% rename from src/auth/process_tests.rs rename to crates/git-same-core/src/auth/process_tests.rs diff --git a/src/auth/ssh.rs b/crates/git-same-core/src/auth/ssh.rs similarity index 100% rename from src/auth/ssh.rs rename to crates/git-same-core/src/auth/ssh.rs diff --git a/src/auth/ssh_tests.rs b/crates/git-same-core/src/auth/ssh_tests.rs similarity index 100% rename from src/auth/ssh_tests.rs rename to crates/git-same-core/src/auth/ssh_tests.rs diff --git a/src/cache/discovery.rs b/crates/git-same-core/src/cache/discovery.rs similarity index 100% rename from src/cache/discovery.rs rename to crates/git-same-core/src/cache/discovery.rs diff --git a/src/cache/discovery_tests.rs b/crates/git-same-core/src/cache/discovery_tests.rs similarity index 100% rename from src/cache/discovery_tests.rs rename to crates/git-same-core/src/cache/discovery_tests.rs diff --git a/src/cache/mod.rs b/crates/git-same-core/src/cache/mod.rs similarity index 100% rename from src/cache/mod.rs rename to crates/git-same-core/src/cache/mod.rs diff --git a/src/cache/sync_history.rs b/crates/git-same-core/src/cache/sync_history.rs similarity index 100% rename from src/cache/sync_history.rs rename to crates/git-same-core/src/cache/sync_history.rs diff --git a/src/cache/sync_history_tests.rs b/crates/git-same-core/src/cache/sync_history_tests.rs similarity index 100% rename from src/cache/sync_history_tests.rs rename to crates/git-same-core/src/cache/sync_history_tests.rs diff --git a/src/checks.rs b/crates/git-same-core/src/checks.rs similarity index 100% rename from src/checks.rs rename to crates/git-same-core/src/checks.rs diff --git a/src/checks_tests.rs b/crates/git-same-core/src/checks_tests.rs similarity index 100% rename from src/checks_tests.rs rename to crates/git-same-core/src/checks_tests.rs diff --git a/src/config/mod.rs b/crates/git-same-core/src/config/mod.rs similarity index 100% rename from src/config/mod.rs rename to crates/git-same-core/src/config/mod.rs diff --git a/src/config/parser.rs b/crates/git-same-core/src/config/parser.rs similarity index 100% rename from src/config/parser.rs rename to crates/git-same-core/src/config/parser.rs diff --git a/src/config/parser_tests.rs b/crates/git-same-core/src/config/parser_tests.rs similarity index 100% rename from src/config/parser_tests.rs rename to crates/git-same-core/src/config/parser_tests.rs diff --git a/src/config/provider_config.rs b/crates/git-same-core/src/config/provider_config.rs similarity index 100% rename from src/config/provider_config.rs rename to crates/git-same-core/src/config/provider_config.rs diff --git a/src/config/provider_config_tests.rs b/crates/git-same-core/src/config/provider_config_tests.rs similarity index 100% rename from src/config/provider_config_tests.rs rename to crates/git-same-core/src/config/provider_config_tests.rs diff --git a/src/config/workspace.rs b/crates/git-same-core/src/config/workspace.rs similarity index 100% rename from src/config/workspace.rs rename to crates/git-same-core/src/config/workspace.rs diff --git a/src/config/workspace_manager.rs b/crates/git-same-core/src/config/workspace_manager.rs similarity index 100% rename from src/config/workspace_manager.rs rename to crates/git-same-core/src/config/workspace_manager.rs diff --git a/src/config/workspace_manager_tests.rs b/crates/git-same-core/src/config/workspace_manager_tests.rs similarity index 100% rename from src/config/workspace_manager_tests.rs rename to crates/git-same-core/src/config/workspace_manager_tests.rs diff --git a/src/config/workspace_policy.rs b/crates/git-same-core/src/config/workspace_policy.rs similarity index 100% rename from src/config/workspace_policy.rs rename to crates/git-same-core/src/config/workspace_policy.rs diff --git a/src/config/workspace_policy_tests.rs b/crates/git-same-core/src/config/workspace_policy_tests.rs similarity index 100% rename from src/config/workspace_policy_tests.rs rename to crates/git-same-core/src/config/workspace_policy_tests.rs diff --git a/src/config/workspace_store.rs b/crates/git-same-core/src/config/workspace_store.rs similarity index 100% rename from src/config/workspace_store.rs rename to crates/git-same-core/src/config/workspace_store.rs diff --git a/src/config/workspace_store_tests.rs b/crates/git-same-core/src/config/workspace_store_tests.rs similarity index 100% rename from src/config/workspace_store_tests.rs rename to crates/git-same-core/src/config/workspace_store_tests.rs diff --git a/src/config/workspace_tests.rs b/crates/git-same-core/src/config/workspace_tests.rs similarity index 100% rename from src/config/workspace_tests.rs rename to crates/git-same-core/src/config/workspace_tests.rs diff --git a/src/discovery.rs b/crates/git-same-core/src/discovery.rs similarity index 100% rename from src/discovery.rs rename to crates/git-same-core/src/discovery.rs diff --git a/src/discovery_tests.rs b/crates/git-same-core/src/discovery_tests.rs similarity index 100% rename from src/discovery_tests.rs rename to crates/git-same-core/src/discovery_tests.rs diff --git a/src/domain/mod.rs b/crates/git-same-core/src/domain/mod.rs similarity index 100% rename from src/domain/mod.rs rename to crates/git-same-core/src/domain/mod.rs diff --git a/src/domain/repo_path_template.rs b/crates/git-same-core/src/domain/repo_path_template.rs similarity index 100% rename from src/domain/repo_path_template.rs rename to crates/git-same-core/src/domain/repo_path_template.rs diff --git a/src/domain/repo_path_template_tests.rs b/crates/git-same-core/src/domain/repo_path_template_tests.rs similarity index 100% rename from src/domain/repo_path_template_tests.rs rename to crates/git-same-core/src/domain/repo_path_template_tests.rs diff --git a/src/errors/app.rs b/crates/git-same-core/src/errors/app.rs similarity index 100% rename from src/errors/app.rs rename to crates/git-same-core/src/errors/app.rs diff --git a/src/errors/app_tests.rs b/crates/git-same-core/src/errors/app_tests.rs similarity index 100% rename from src/errors/app_tests.rs rename to crates/git-same-core/src/errors/app_tests.rs diff --git a/src/errors/git.rs b/crates/git-same-core/src/errors/git.rs similarity index 100% rename from src/errors/git.rs rename to crates/git-same-core/src/errors/git.rs diff --git a/src/errors/git_tests.rs b/crates/git-same-core/src/errors/git_tests.rs similarity index 100% rename from src/errors/git_tests.rs rename to crates/git-same-core/src/errors/git_tests.rs diff --git a/src/errors/mod.rs b/crates/git-same-core/src/errors/mod.rs similarity index 91% rename from src/errors/mod.rs rename to crates/git-same-core/src/errors/mod.rs index 72660ff..8fa8f57 100644 --- a/src/errors/mod.rs +++ b/crates/git-same-core/src/errors/mod.rs @@ -8,7 +8,7 @@ //! # Example //! //! ``` -//! use git_same::errors::{AppError, Result}; +//! use git_same_core::errors::{AppError, Result}; //! //! fn do_something() -> Result<()> { //! Err(AppError::config("missing required field")) diff --git a/src/errors/provider.rs b/crates/git-same-core/src/errors/provider.rs similarity index 100% rename from src/errors/provider.rs rename to crates/git-same-core/src/errors/provider.rs diff --git a/src/errors/provider_tests.rs b/crates/git-same-core/src/errors/provider_tests.rs similarity index 100% rename from src/errors/provider_tests.rs rename to crates/git-same-core/src/errors/provider_tests.rs diff --git a/src/git/mod.rs b/crates/git-same-core/src/git/mod.rs similarity index 94% rename from src/git/mod.rs rename to crates/git-same-core/src/git/mod.rs index c119b45..cf2af6a 100644 --- a/src/git/mod.rs +++ b/crates/git-same-core/src/git/mod.rs @@ -13,7 +13,7 @@ //! # Example //! //! ```no_run -//! use git_same::git::{ShellGit, GitOperations, CloneOptions}; +//! use git_same_core::git::{ShellGit, GitOperations, CloneOptions}; //! use std::path::Path; //! //! let git = ShellGit::new(); diff --git a/src/git/mod_tests.rs b/crates/git-same-core/src/git/mod_tests.rs similarity index 100% rename from src/git/mod_tests.rs rename to crates/git-same-core/src/git/mod_tests.rs diff --git a/src/git/shell.rs b/crates/git-same-core/src/git/shell.rs similarity index 100% rename from src/git/shell.rs rename to crates/git-same-core/src/git/shell.rs diff --git a/src/git/shell_tests.rs b/crates/git-same-core/src/git/shell_tests.rs similarity index 100% rename from src/git/shell_tests.rs rename to crates/git-same-core/src/git/shell_tests.rs diff --git a/src/git/traits.rs b/crates/git-same-core/src/git/traits.rs similarity index 100% rename from src/git/traits.rs rename to crates/git-same-core/src/git/traits.rs diff --git a/src/git/traits_tests.rs b/crates/git-same-core/src/git/traits_tests.rs similarity index 100% rename from src/git/traits_tests.rs rename to crates/git-same-core/src/git/traits_tests.rs diff --git a/src/infra/mod.rs b/crates/git-same-core/src/infra/mod.rs similarity index 100% rename from src/infra/mod.rs rename to crates/git-same-core/src/infra/mod.rs diff --git a/src/infra/storage/mod.rs b/crates/git-same-core/src/infra/storage/mod.rs similarity index 100% rename from src/infra/storage/mod.rs rename to crates/git-same-core/src/infra/storage/mod.rs diff --git a/src/ipc/mod.rs b/crates/git-same-core/src/ipc/mod.rs similarity index 100% rename from src/ipc/mod.rs rename to crates/git-same-core/src/ipc/mod.rs diff --git a/src/ipc/mod_tests.rs b/crates/git-same-core/src/ipc/mod_tests.rs similarity index 100% rename from src/ipc/mod_tests.rs rename to crates/git-same-core/src/ipc/mod_tests.rs diff --git a/src/ipc/status_file.rs b/crates/git-same-core/src/ipc/status_file.rs similarity index 100% rename from src/ipc/status_file.rs rename to crates/git-same-core/src/ipc/status_file.rs diff --git a/src/ipc/status_file_tests.rs b/crates/git-same-core/src/ipc/status_file_tests.rs similarity index 100% rename from src/ipc/status_file_tests.rs rename to crates/git-same-core/src/ipc/status_file_tests.rs diff --git a/src/ipc/unix_socket.rs b/crates/git-same-core/src/ipc/unix_socket.rs similarity index 100% rename from src/ipc/unix_socket.rs rename to crates/git-same-core/src/ipc/unix_socket.rs diff --git a/src/ipc/unix_socket_tests.rs b/crates/git-same-core/src/ipc/unix_socket_tests.rs similarity index 100% rename from src/ipc/unix_socket_tests.rs rename to crates/git-same-core/src/ipc/unix_socket_tests.rs diff --git a/crates/git-same-core/src/lib.rs b/crates/git-same-core/src/lib.rs new file mode 100644 index 0000000..6a7505c --- /dev/null +++ b/crates/git-same-core/src/lib.rs @@ -0,0 +1,58 @@ +//! # git-same-core +//! +//! Engine for git-same: discovery, clone/sync orchestration, IPC, status. +//! See the `git-same` (CLI) crate for the user-facing binary. + +pub mod api; +pub mod auth; +pub mod cache; +pub mod checks; +pub mod config; +pub mod discovery; +pub mod domain; +pub mod errors; +pub mod git; +pub mod infra; +pub mod ipc; +pub mod operations; +pub mod output; +pub mod provider; +pub mod types; +pub mod workflows; + +/// Re-export commonly used types for convenience. +pub mod prelude { + pub use crate::auth::{get_auth, get_auth_for_provider, AuthResult}; + pub use crate::cache::{CacheManager, DiscoveryCache, SyncHistoryManager, CACHE_VERSION}; + pub use crate::config::{ + Config, ConfigCloneOptions, FilterOptions, SyncMode as ConfigSyncMode, WorkspaceConfig, + WorkspaceProvider, + }; + pub use crate::discovery::DiscoveryOrchestrator; + pub use crate::domain::RepoPathTemplate; + pub use crate::errors::{AppError, GitError, ProviderError, Result}; + pub use crate::git::{ + CloneOptions, FetchResult, GitOperations, PullResult, RepoStatus, ShellGit, + }; + pub use crate::operations::clone::{ + CloneManager, CloneManagerOptions, CloneProgress, CloneResult, + }; + pub use crate::operations::sync::{ + LocalRepo, SyncManager, SyncManagerOptions, SyncMode, SyncResult, + }; + pub use crate::output::{ + CloneProgressBar, DiscoveryProgressBar, Output, SyncProgressBar, Verbosity, + }; + pub use crate::provider::{ + create_provider, Credentials, DiscoveryOptions, DiscoveryProgress, NoProgress, Provider, + RateLimitInfo, + }; + pub use crate::types::{ + ActionPlan, OpResult, OpSummary, Org, OwnedRepo, ProviderKind, Repo, RepoEntry, + SyncHistoryEntry, + }; +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/src/lib_tests.rs b/crates/git-same-core/src/lib_tests.rs similarity index 100% rename from src/lib_tests.rs rename to crates/git-same-core/src/lib_tests.rs diff --git a/src/operations/clone.rs b/crates/git-same-core/src/operations/clone.rs similarity index 98% rename from src/operations/clone.rs rename to crates/git-same-core/src/operations/clone.rs index ced17e2..1384733 100644 --- a/src/operations/clone.rs +++ b/crates/git-same-core/src/operations/clone.rs @@ -6,8 +6,8 @@ //! # Example //! //! ```no_run -//! use git_same::operations::clone::{CloneManager, CloneManagerOptions, NoProgress}; -//! use git_same::git::ShellGit; +//! use git_same_core::operations::clone::{CloneManager, CloneManagerOptions, NoProgress}; +//! use git_same_core::git::ShellGit; //! use std::path::Path; //! //! # async fn example() { diff --git a/src/operations/clone_tests.rs b/crates/git-same-core/src/operations/clone_tests.rs similarity index 100% rename from src/operations/clone_tests.rs rename to crates/git-same-core/src/operations/clone_tests.rs diff --git a/src/operations/mod.rs b/crates/git-same-core/src/operations/mod.rs similarity index 100% rename from src/operations/mod.rs rename to crates/git-same-core/src/operations/mod.rs diff --git a/src/operations/sync.rs b/crates/git-same-core/src/operations/sync.rs similarity index 99% rename from src/operations/sync.rs rename to crates/git-same-core/src/operations/sync.rs index c928c0c..6cd725e 100644 --- a/src/operations/sync.rs +++ b/crates/git-same-core/src/operations/sync.rs @@ -6,9 +6,9 @@ //! # Example //! //! ```no_run -//! use git_same::operations::sync::{SyncManager, SyncManagerOptions, SyncMode, LocalRepo, NoSyncProgress}; -//! use git_same::git::ShellGit; -//! use git_same::types::{OwnedRepo, Repo}; +//! use git_same_core::operations::sync::{SyncManager, SyncManagerOptions, SyncMode, LocalRepo, NoSyncProgress}; +//! use git_same_core::git::ShellGit; +//! use git_same_core::types::{OwnedRepo, Repo}; //! use std::path::PathBuf; //! //! # async fn example() { diff --git a/src/operations/sync_tests.rs b/crates/git-same-core/src/operations/sync_tests.rs similarity index 100% rename from src/operations/sync_tests.rs rename to crates/git-same-core/src/operations/sync_tests.rs diff --git a/src/output/mod.rs b/crates/git-same-core/src/output/mod.rs similarity index 100% rename from src/output/mod.rs rename to crates/git-same-core/src/output/mod.rs diff --git a/src/output/printer.rs b/crates/git-same-core/src/output/printer.rs similarity index 100% rename from src/output/printer.rs rename to crates/git-same-core/src/output/printer.rs diff --git a/src/output/printer_tests.rs b/crates/git-same-core/src/output/printer_tests.rs similarity index 100% rename from src/output/printer_tests.rs rename to crates/git-same-core/src/output/printer_tests.rs diff --git a/src/output/progress/clone.rs b/crates/git-same-core/src/output/progress/clone.rs similarity index 100% rename from src/output/progress/clone.rs rename to crates/git-same-core/src/output/progress/clone.rs diff --git a/src/output/progress/clone_tests.rs b/crates/git-same-core/src/output/progress/clone_tests.rs similarity index 100% rename from src/output/progress/clone_tests.rs rename to crates/git-same-core/src/output/progress/clone_tests.rs diff --git a/src/output/progress/discovery.rs b/crates/git-same-core/src/output/progress/discovery.rs similarity index 100% rename from src/output/progress/discovery.rs rename to crates/git-same-core/src/output/progress/discovery.rs diff --git a/src/output/progress/discovery_tests.rs b/crates/git-same-core/src/output/progress/discovery_tests.rs similarity index 100% rename from src/output/progress/discovery_tests.rs rename to crates/git-same-core/src/output/progress/discovery_tests.rs diff --git a/src/output/progress/mod.rs b/crates/git-same-core/src/output/progress/mod.rs similarity index 100% rename from src/output/progress/mod.rs rename to crates/git-same-core/src/output/progress/mod.rs diff --git a/src/output/progress/styles.rs b/crates/git-same-core/src/output/progress/styles.rs similarity index 100% rename from src/output/progress/styles.rs rename to crates/git-same-core/src/output/progress/styles.rs diff --git a/src/output/progress/sync.rs b/crates/git-same-core/src/output/progress/sync.rs similarity index 100% rename from src/output/progress/sync.rs rename to crates/git-same-core/src/output/progress/sync.rs diff --git a/src/output/progress/sync_tests.rs b/crates/git-same-core/src/output/progress/sync_tests.rs similarity index 100% rename from src/output/progress/sync_tests.rs rename to crates/git-same-core/src/output/progress/sync_tests.rs diff --git a/src/provider/github/client.rs b/crates/git-same-core/src/provider/github/client.rs similarity index 100% rename from src/provider/github/client.rs rename to crates/git-same-core/src/provider/github/client.rs diff --git a/src/provider/github/client_tests.rs b/crates/git-same-core/src/provider/github/client_tests.rs similarity index 100% rename from src/provider/github/client_tests.rs rename to crates/git-same-core/src/provider/github/client_tests.rs diff --git a/src/provider/github/mod.rs b/crates/git-same-core/src/provider/github/mod.rs similarity index 100% rename from src/provider/github/mod.rs rename to crates/git-same-core/src/provider/github/mod.rs diff --git a/src/provider/github/pagination.rs b/crates/git-same-core/src/provider/github/pagination.rs similarity index 100% rename from src/provider/github/pagination.rs rename to crates/git-same-core/src/provider/github/pagination.rs diff --git a/src/provider/github/pagination_tests.rs b/crates/git-same-core/src/provider/github/pagination_tests.rs similarity index 100% rename from src/provider/github/pagination_tests.rs rename to crates/git-same-core/src/provider/github/pagination_tests.rs diff --git a/src/provider/mock.rs b/crates/git-same-core/src/provider/mock.rs similarity index 100% rename from src/provider/mock.rs rename to crates/git-same-core/src/provider/mock.rs diff --git a/src/provider/mock_tests.rs b/crates/git-same-core/src/provider/mock_tests.rs similarity index 100% rename from src/provider/mock_tests.rs rename to crates/git-same-core/src/provider/mock_tests.rs diff --git a/src/provider/mod.rs b/crates/git-same-core/src/provider/mod.rs similarity index 89% rename from src/provider/mod.rs rename to crates/git-same-core/src/provider/mod.rs index 5797c4a..6ae5e21 100644 --- a/src/provider/mod.rs +++ b/crates/git-same-core/src/provider/mod.rs @@ -12,10 +12,10 @@ //! # Example //! //! ```no_run -//! use git_same::provider::{create_provider, DiscoveryOptions, NoProgress}; -//! use git_same::config::WorkspaceProvider; +//! use git_same_core::provider::{create_provider, DiscoveryOptions, NoProgress}; +//! use git_same_core::config::WorkspaceProvider; //! -//! # async fn example() -> Result<(), git_same::errors::AppError> { +//! # async fn example() -> Result<(), git_same_core::errors::AppError> { //! let provider = WorkspaceProvider::default(); //! let p = create_provider(&provider, "ghp_token123")?; //! diff --git a/src/provider/mod_tests.rs b/crates/git-same-core/src/provider/mod_tests.rs similarity index 100% rename from src/provider/mod_tests.rs rename to crates/git-same-core/src/provider/mod_tests.rs diff --git a/src/provider/traits.rs b/crates/git-same-core/src/provider/traits.rs similarity index 100% rename from src/provider/traits.rs rename to crates/git-same-core/src/provider/traits.rs diff --git a/src/provider/traits_tests.rs b/crates/git-same-core/src/provider/traits_tests.rs similarity index 100% rename from src/provider/traits_tests.rs rename to crates/git-same-core/src/provider/traits_tests.rs diff --git a/src/types/finder_status.rs b/crates/git-same-core/src/types/finder_status.rs similarity index 100% rename from src/types/finder_status.rs rename to crates/git-same-core/src/types/finder_status.rs diff --git a/src/types/finder_status_tests.rs b/crates/git-same-core/src/types/finder_status_tests.rs similarity index 100% rename from src/types/finder_status_tests.rs rename to crates/git-same-core/src/types/finder_status_tests.rs diff --git a/src/types/mod.rs b/crates/git-same-core/src/types/mod.rs similarity index 100% rename from src/types/mod.rs rename to crates/git-same-core/src/types/mod.rs diff --git a/src/types/provider.rs b/crates/git-same-core/src/types/provider.rs similarity index 100% rename from src/types/provider.rs rename to crates/git-same-core/src/types/provider.rs diff --git a/src/types/provider_tests.rs b/crates/git-same-core/src/types/provider_tests.rs similarity index 100% rename from src/types/provider_tests.rs rename to crates/git-same-core/src/types/provider_tests.rs diff --git a/src/types/repo.rs b/crates/git-same-core/src/types/repo.rs similarity index 98% rename from src/types/repo.rs rename to crates/git-same-core/src/types/repo.rs index 573b31e..2ebf14e 100644 --- a/src/types/repo.rs +++ b/crates/git-same-core/src/types/repo.rs @@ -63,7 +63,7 @@ pub struct Repo { impl Repo { /// Creates a minimal repo for testing. - #[cfg(test)] + #[cfg(any(test, feature = "test-utils"))] pub fn test(name: &str, owner: &str) -> Self { Self { id: rand_id(), @@ -86,7 +86,7 @@ impl Repo { } } -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] fn rand_id() -> u64 { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/src/types/repo_status.rs b/crates/git-same-core/src/types/repo_status.rs similarity index 100% rename from src/types/repo_status.rs rename to crates/git-same-core/src/types/repo_status.rs diff --git a/src/types/repo_tests.rs b/crates/git-same-core/src/types/repo_tests.rs similarity index 100% rename from src/types/repo_tests.rs rename to crates/git-same-core/src/types/repo_tests.rs diff --git a/src/workflows/mod.rs b/crates/git-same-core/src/workflows/mod.rs similarity index 100% rename from src/workflows/mod.rs rename to crates/git-same-core/src/workflows/mod.rs diff --git a/src/workflows/status_scan.rs b/crates/git-same-core/src/workflows/status_scan.rs similarity index 100% rename from src/workflows/status_scan.rs rename to crates/git-same-core/src/workflows/status_scan.rs diff --git a/src/workflows/status_scan_tests.rs b/crates/git-same-core/src/workflows/status_scan_tests.rs similarity index 100% rename from src/workflows/status_scan_tests.rs rename to crates/git-same-core/src/workflows/status_scan_tests.rs diff --git a/src/workflows/sync_workspace.rs b/crates/git-same-core/src/workflows/sync_workspace.rs similarity index 100% rename from src/workflows/sync_workspace.rs rename to crates/git-same-core/src/workflows/sync_workspace.rs diff --git a/src/workflows/sync_workspace_tests.rs b/crates/git-same-core/src/workflows/sync_workspace_tests.rs similarity index 100% rename from src/workflows/sync_workspace_tests.rs rename to crates/git-same-core/src/workflows/sync_workspace_tests.rs diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 879720a..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! # Git-Same - Mirror GitHub org/repo structure locally -//! -//! Git-Same is a CLI tool that discovers all GitHub organizations -//! and repositories you have access to, then clones them to your local filesystem -//! maintaining the org/repo directory structure. -//! -//! ## Features -//! -//! - **Multi-Provider Support**: Works with GitHub (more providers coming soon) -//! - **Parallel Operations**: Clones and syncs repositories concurrently -//! - **Smart Filtering**: Filter by archived status, forks, organizations -//! - **Incremental Sync**: Only fetches/pulls what has changed -//! - **Progress Reporting**: Beautiful progress bars and status updates -//! -//! ## Available Commands -//! -//! The tool can be invoked using any of these names (all installed by default): -//! - `git-same` - Main command -//! - `gitsame` - No hyphen variant -//! - `gitsa` - Short form -//! - `gisa` - Shortest variant -//! - `git same` - Git subcommand (requires git-same in PATH) -//! -//! ## Example -//! -//! ```bash -//! # Initialize configuration -//! git-same init -//! -//! # Set up a workspace -//! git-same setup -//! -//! # Sync repositories (clone new + fetch existing) -//! git-same sync --dry-run -//! git-same sync -//! -//! # Show status -//! git-same status -//! -//! # Also works as git subcommand -//! git same sync -//! ``` - -pub mod api; -pub mod app; -pub mod auth; -pub mod banner; -pub mod cache; -pub mod checks; -pub mod cli; -pub mod commands; -pub mod config; -pub mod discovery; -pub mod domain; -pub mod errors; -pub mod git; -pub mod infra; -pub mod ipc; -pub mod operations; -pub mod output; -pub mod provider; -pub mod setup; -#[cfg(feature = "tui")] -pub mod tui; -pub mod types; -pub mod workflows; - -/// Re-export commonly used types for convenience. -pub mod prelude { - pub use crate::auth::{get_auth, get_auth_for_provider, AuthResult}; - pub use crate::cache::{CacheManager, DiscoveryCache, SyncHistoryManager, CACHE_VERSION}; - pub use crate::cli::{Cli, Command, InitArgs, ResetArgs, StatusArgs, SyncCmdArgs}; - pub use crate::config::{ - Config, ConfigCloneOptions, FilterOptions, SyncMode as ConfigSyncMode, WorkspaceConfig, - WorkspaceProvider, - }; - pub use crate::discovery::DiscoveryOrchestrator; - pub use crate::domain::RepoPathTemplate; - pub use crate::errors::{AppError, GitError, ProviderError, Result}; - pub use crate::git::{ - CloneOptions, FetchResult, GitOperations, PullResult, RepoStatus, ShellGit, - }; - pub use crate::operations::clone::{ - CloneManager, CloneManagerOptions, CloneProgress, CloneResult, - }; - pub use crate::operations::sync::{ - LocalRepo, SyncManager, SyncManagerOptions, SyncMode, SyncResult, - }; - pub use crate::output::{ - CloneProgressBar, DiscoveryProgressBar, Output, SyncProgressBar, Verbosity, - }; - pub use crate::provider::{ - create_provider, Credentials, DiscoveryOptions, DiscoveryProgress, NoProgress, Provider, - RateLimitInfo, - }; - pub use crate::types::{ActionPlan, OpResult, OpSummary, Org, OwnedRepo, ProviderKind, Repo}; -} - -#[cfg(test)] -#[path = "lib_tests.rs"] -mod tests; From fbcd478f42ac88e42a163b4d4f299b4c4aa9c695 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 19:34:57 +0200 Subject: [PATCH 31/89] Update CI workflows and toolkit scripts for the workspace layout - S1 / S2 clippy and test commands gain --workspace; the release-build step in S2 builds -p git-same-cli explicitly. The alias-drift-check awk parser is retargeted at crates/git-same-cli/Cargo.toml since the root manifest no longer holds [[bin]] entries. - S4 publishes git-same-core first, polls crates.io until it's indexed, then publishes git-same-cli. The poll exists because the second publish resolves the first via the registry, not the path dep. - toolkit/packaging/gen-completions.sh and gen-manpage.sh now pass -p git-same-cli to disambiguate the workspace member that owns the release-tools bins. - toolkit/conductor/run.sh and setup.sh point cargo install at crates/git-same-cli instead of `.`. S3-Publish-Homebrew.yml is unchanged (it downloads pre-built tarballs and renders the formula/cask; no cargo invocations). Phase B5 of the workspace refactor. --- .github/workflows/S1-Test-CI.yml | 8 ++++---- .github/workflows/S2-Release-GitHub.yml | 8 ++++---- .github/workflows/S4-Publish-Crates.yml | 25 +++++++++++++++++++++++-- toolkit/conductor/run.sh | 4 ++-- toolkit/conductor/setup.sh | 2 +- toolkit/packaging/gen-completions.sh | 1 + toolkit/packaging/gen-manpage.sh | 1 + 7 files changed, 36 insertions(+), 13 deletions(-) diff --git a/.github/workflows/S1-Test-CI.yml b/.github/workflows/S1-Test-CI.yml index a64f36e..dabcf41 100644 --- a/.github/workflows/S1-Test-CI.yml +++ b/.github/workflows/S1-Test-CI.yml @@ -43,10 +43,10 @@ jobs: run: cargo +${{ matrix.rust }} fmt --all -- --check - name: Clippy - run: cargo +${{ matrix.rust }} clippy --all-targets --all-features -- -D warnings + run: cargo +${{ matrix.rust }} clippy --workspace --all-targets --all-features -- -D warnings - name: Run tests - run: cargo +${{ matrix.rust }} test --all-features -- --test-threads=1 + run: cargo +${{ matrix.rust }} test --workspace --all-features -- --test-threads=1 build: name: Build (${{ matrix.target }}) @@ -81,7 +81,7 @@ jobs: prefix-key: v1-rust-no-bin - name: Build release - run: cargo +stable build --release --target ${{ matrix.target }} + run: cargo +stable build --release -p git-same-cli --target ${{ matrix.target }} coverage: name: Code Coverage @@ -161,7 +161,7 @@ jobs: } in_bin && /^[[:space:]]*required-features[[:space:]]*=/ { has_req=1 } END { flush(); print default_count, default_name } - ' Cargo.toml)" + ' crates/git-same-cli/Cargo.toml)" if [ "${DEFAULT_COUNT:-0}" -ne 1 ]; then echo "ERROR: Cargo.toml has ${DEFAULT_COUNT:-0} default [[bin]] entries, expected 1"; exit 1 fi diff --git a/.github/workflows/S2-Release-GitHub.yml b/.github/workflows/S2-Release-GitHub.yml index 4fd9060..d8fb8fc 100644 --- a/.github/workflows/S2-Release-GitHub.yml +++ b/.github/workflows/S2-Release-GitHub.yml @@ -40,10 +40,10 @@ jobs: run: cargo +${{ matrix.rust }} fmt --all -- --check - name: Clippy - run: cargo +${{ matrix.rust }} clippy --all-targets --all-features -- -D warnings + run: cargo +${{ matrix.rust }} clippy --workspace --all-targets --all-features -- -D warnings - name: Run tests - run: cargo +${{ matrix.rust }} test --all-features -- --test-threads=1 + run: cargo +${{ matrix.rust }} test --workspace --all-features -- --test-threads=1 coverage: name: Code Coverage @@ -115,7 +115,7 @@ jobs: } in_bin && /^[[:space:]]*required-features[[:space:]]*=/ { has_req=1 } END { flush(); print default_count, default_name } - ' Cargo.toml)" + ' crates/git-same-cli/Cargo.toml)" if [ "${DEFAULT_COUNT:-0}" -ne 1 ]; then echo "ERROR: Cargo.toml has ${DEFAULT_COUNT:-0} default [[bin]] entries, expected 1"; exit 1 fi @@ -267,7 +267,7 @@ jobs: prefix-key: v1-rust-no-bin - name: Build - run: cargo +stable build --release --target ${{ matrix.target }} + run: cargo +stable build --release -p git-same-cli --target ${{ matrix.target }} - name: Resolve version from tag if: startsWith(github.ref, 'refs/tags/') diff --git a/.github/workflows/S4-Publish-Crates.yml b/.github/workflows/S4-Publish-Crates.yml index ee76e36..0e09572 100644 --- a/.github/workflows/S4-Publish-Crates.yml +++ b/.github/workflows/S4-Publish-Crates.yml @@ -23,7 +23,28 @@ jobs: - uses: Swatinem/rust-cache@v2 - - name: Publish - run: cargo +stable publish + - name: Publish git-same-core + run: cargo +stable publish -p git-same-core + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + - name: Wait for crates.io to index git-same-core + run: | + set -euo pipefail + VERSION=$(awk -F'"' '/^version *=/ {print $2; exit}' Cargo.toml) + echo "Waiting for git-same-core ${VERSION} to be indexed on crates.io..." + for i in $(seq 1 30); do + if cargo search git-same-core --limit 1 \ + | grep -q "git-same-core[[:space:]]*=[[:space:]]*\"${VERSION}\""; then + echo "Indexed." + exit 0 + fi + sleep 10 + done + echo "Timed out waiting for crates.io to index git-same-core ${VERSION}" >&2 + exit 1 + + - name: Publish git-same-cli + run: cargo +stable publish -p git-same-cli env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/toolkit/conductor/run.sh b/toolkit/conductor/run.sh index 1334b44..6bad3f5 100755 --- a/toolkit/conductor/run.sh +++ b/toolkit/conductor/run.sh @@ -32,8 +32,8 @@ PRIMARY_BIN="${BINARIES[0]}" GS_COMMAND="$CARGO_BIN_DIR/$PRIMARY_BIN" # Install primary binary -echo "Installing with: cargo install --path . --force" -cargo install --path . --force +echo "Installing with: cargo install --path crates/git-same-cli --force" +cargo install --path crates/git-same-cli --force echo "" if [ ! -x "$CARGO_BIN_DIR/$PRIMARY_BIN" ]; then diff --git a/toolkit/conductor/setup.sh b/toolkit/conductor/setup.sh index 097953f..b465de8 100755 --- a/toolkit/conductor/setup.sh +++ b/toolkit/conductor/setup.sh @@ -72,6 +72,6 @@ echo "========================================" echo "" echo "Next steps:" echo " 1. Run: ./toolkit/conductor/run.sh" -echo " 2. Or manually install: cargo install --path . --force" +echo " 2. Or manually install: cargo install --path crates/git-same-cli --force" echo " (then refresh aliases via ./toolkit/conductor/run.sh)" echo "" diff --git a/toolkit/packaging/gen-completions.sh b/toolkit/packaging/gen-completions.sh index 91df448..06227e7 100755 --- a/toolkit/packaging/gen-completions.sh +++ b/toolkit/packaging/gen-completions.sh @@ -35,6 +35,7 @@ for entry in "${SHELLS[@]}"; do echo "==> $SHELL_NAME -> $OUT_PATH" cargo run \ --release \ + -p git-same-cli \ --features release-tools \ --bin gen-completions \ -- "$SHELL_NAME" \ diff --git a/toolkit/packaging/gen-manpage.sh b/toolkit/packaging/gen-manpage.sh index 9c4604b..b2f8233 100755 --- a/toolkit/packaging/gen-manpage.sh +++ b/toolkit/packaging/gen-manpage.sh @@ -24,6 +24,7 @@ OUT_PATH="$OUT_DIR/git-same.1" echo "==> manpage -> $OUT_PATH" cargo run \ --release \ + -p git-same-cli \ --features release-tools \ --bin gen-manpage \ > "$OUT_PATH" From f35cdf4dbde6d123b402a26748ab42608f5d4108 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 19:36:45 +0200 Subject: [PATCH 32/89] Update docs and CLAUDE.md to reflect the workspace split - docs/README.md: cargo install line points at git-same-cli (the new package name) with a note that the binary is still git-same. - docs/DEVELOPMENT.md: build and test commands gain --workspace examples and -p git-same-core / -p git-same-cli for focused runs; cargo install --path is now crates/git-same-cli; integration tests live under that crate. - .claude/CLAUDE.md: replaces the single-crate module map with separate Engine modules (git-same-core) and CLI / TUI modules (git-same-cli) sections, calls out the test-utils feature, and documents the integration-test binary lookup via env!(). - toolkit/packaging/release-checklist.md: cargo install reference updated; transition note added. Phase B7 of the workspace refactor. --- .claude/CLAUDE.md | 90 +++++++++++++++++++------- docs/DEVELOPMENT.md | 32 +++++---- docs/README.md | 6 +- toolkit/packaging/release-checklist.md | 3 +- 4 files changed, 90 insertions(+), 41 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1e1bcd3..e72684c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -7,35 +7,45 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build & Test Commands ```bash -cargo build # Debug build -cargo build --release # Optimized release build (LTO, stripped) -cargo test --all-features -- --test-threads=1 # Run CI-equivalent tests -cargo test # Run a single test by name -cargo test --test integration_test # Run only integration tests -cargo fmt --all -- --check # Check formatting -cargo clippy --all-targets --all-features -- -D warnings # Lint (zero warnings enforced) +cargo build --workspace # Debug build +cargo build --release --workspace # Optimized release (LTO, stripped) +cargo test --workspace # Run all tests +cargo test -p git-same-core # Tests for the engine crate only +cargo test -p git-same # Tests for the CLI crate only +cargo test --workspace # Run a single test by name +cargo test -p git-same --test integration_test # Run only integration tests +cargo fmt --all -- --check # Check formatting +cargo clippy --workspace --all-targets --all-features -- -D warnings # Lint ``` -Logging is controlled via `GISA_LOG` env var (e.g., `GISA_LOG=debug cargo run -- sync`). +Logging is controlled via `GISA_LOG` env var (e.g., `GISA_LOG=debug cargo run -p git-same -- sync`). ## Architecture Git-Same is a Rust CLI + TUI tool that discovers GitHub org/repo structures and mirrors them locally with parallel cloning and syncing. -**Binary aliases:** Cargo defines the primary `git-same` binary at `src/main.rs`. Installers create `gitsame`, `gitsa`, and `gisa` symlinks from `toolkit/packaging/binary-aliases.txt`. +**Workspace layout:** the project is a Cargo workspace with two member crates: + +- `git-same-core` (`crates/git-same-core/`) — the engine library. No UI dependencies (no clap, ratatui, crossterm). Holds discovery, clone/sync, IPC, status scanning, and shared types. +- `git-same` (lives at `crates/git-same-cli/` on disk; the directory name and package name intentionally diverge so `cargo install git-same` keeps working as it has since pre-3.x) — the CLI binary + TUI. Depends on `git-same-core`. Owns clap parsing, the TUI screens, the setup wizard, and command handlers. The produced binary is named `git-same` (per `[[bin]]` name) so installer aliases (`gisa`, `gitsa`, `gitsame`) and `target/release/git-same` are unchanged from the pre-split layout. + +**Binary aliases:** `git-same`, `gitsame`, `gitsa`, `gisa` — all resolve to the binary built from `crates/git-same-cli/src/main.rs`. **Dual mode:** Running with a subcommand (`gisa sync`) uses the CLI path. Running without a subcommand (`gisa`) launches the interactive TUI. -**CLI flow:** CLI parsing (`src/cli.rs`) → `main.rs` routes to command handler → handler orchestrates modules. +**macOS host strategy:** Two macOS host apps coexist. The Tauri host (`crates/git-same-app/`, Svelte + TypeScript + Vite) is the primary GUI shipped via the cask. The SwiftUI host (`macos/GitSameSwiftApp/`) is intentionally kept as a fallback per the Phase C plan's "perfect macOS feel" escape hatch (`.context/plans/phase-c-tauri-app.md` §2). **Do not delete `macos/GitSameSwiftApp/` without explicit approval** — this overrides the migration plan's earlier "delete in Phase C" instruction. -**Commands:** `init`, `setup`, `sync`, `status`, `scan`, `workspace {list,default}`, `reset`. +**CLI flow:** CLI parsing (`crates/git-same-cli/src/cli.rs`) → `main.rs` routes to command handler → handler orchestrates engine modules from `git-same-core`. -### Core modules +**Commands:** `init`, `setup`, `sync`, `status`, `scan`, `workspace {list,default}`, `reset`, `monitor` (alias: `daemon`), `refresh`. + +**Why `monitor` is a CLI subcommand and not solely a Tauri-host responsibility:** the LaunchAgent invokes `gisa monitor --foreground`, non-cask installs (`cargo install`, the homebrew formula) ship only the binary, `--status` / `--stop` are the supported debugging surface, and a future Linux file-manager extension would talk to the same `gisa monitor` over the same Unix socket. The CLI handler is a thin shim (~140 lines); the loop itself lives in `git-same-core::monitor`. + +### Engine modules (`crates/git-same-core/src/`) -- **`app/`** — Top-level entry points: `app/cli/` runs the CLI subcommand path, `app/tui/` boots the interactive TUI. `main.rs` dispatches to one or the other based on whether a subcommand was given -- **`commands/`** — Per-subcommand handlers (`init`, `setup`, `sync_cmd`, `status`, `scan`, `reset`, `workspace`) plus shared `support/` helpers -- **`workflows/`** — Cross-cutting orchestration shared by CLI and TUI: `sync_workspace` (discover + clone + fetch/pull) and `status_scan` (walk local repos, collect git status) - **`auth/`** — `gh_cli.rs` obtains GitHub API tokens via `gh auth token`. `ssh.rs` exposes low-level SSH probing primitives (`SshProbeResult`, `parse_ssh_probe_output`) used by clone-time diagnostics +- **`workflows/`** — Cross-cutting orchestration: `sync_workspace` (discover + clone + fetch/pull) and `status_scan` (walk local repos, collect git status) +- **`monitor/`**: Long-running monitor loop (periodic scan + Unix-socket server) used by `gisa monitor` and reusable by host apps like the Tauri GUI - **`config/`** — TOML config parser. Default: `~/.config/git-same/config.toml`. Top-level keys: `workspaces`, `default_workspace`, plus `[clone]` and `[filters]` sections - **`discovery.rs`** — `DiscoveryOrchestrator` coordinates repo discovery via providers, applies filters, builds `ActionPlan` (what to clone vs sync) - **`operations/clone.rs`** — `CloneManager` handles concurrent cloning (configurable 1–32, default 4) @@ -45,14 +55,24 @@ Git-Same is a Rust CLI + TUI tool that discovers GitHub org/repo structures and - **`cache/`** — `discovery.rs` provides `DiscoveryCache` (TTL-based validity, persisted at `/.git-same/cache.json`); `sync_history.rs` records sync runs at `/.git-same/sync-history.json` - **`domain/`** — Domain primitives, currently `repo_path_template.rs` for resolving `{org}/{repo}` style structures - **`infra/storage/`** — Storage abstractions for workspace-local persistence -- **`setup/`** — Setup wizard state machine, shared between the CLI `setup` command and the TUI workspace-setup screen +- **`ipc/`** — Monitor ↔ Finder-extension interface (`status_file.rs`, `unix_socket.rs`) +- **`api/`** — Higher-level service helpers built on top of git/provider/config (e.g. `RepoScanService`) - **`errors/`** — Custom error hierarchy: `AppError`, `GitError`, `ProviderError` with `suggested_action()` methods - **`output/`** — `printer.rs` for verbosity-aware text output; `progress/` holds the `indicatif` progress bars (`CloneProgressBar`, `SyncProgressBar`, `DiscoveryProgressBar`) -- **`types/repo.rs`** — Core data types: `Repo`, `Org`, `ActionPlan`, `OpResult`, `OpSummary` +- **`types/`** — Core data types: `Repo`, `Org`, `ActionPlan`, `OpResult`, `OpSummary`, plus `RepoEntry`/`SyncHistoryEntry` (lifted out of the TUI in B0.1) - **`checks.rs`** — System/runtime checks (presence of `git`, `gh`, auth status, SSH access via `check_ssh_github_access`) + +### CLI / TUI modules (`crates/git-same-cli/src/`) + +- **`app/`** — Top-level entry points: `app/cli/` runs the CLI subcommand path, `app/tui/` boots the interactive TUI. `main.rs` dispatches to one or the other based on whether a subcommand was given +- **`commands/`** — Per-subcommand handlers (`init`, `setup`, `sync_cmd`, `status`, `scan`, `reset`, `workspace`, `monitor`, `refresh`) plus shared `support/` helpers +- **`setup/`** — Setup wizard state machine + ratatui rendering, shared between the CLI `setup` command and the TUI workspace-setup screen (gated by the `tui` feature) +- **`tui/`** — Ratatui-based TUI (gated by the `tui` feature) +- **`cli.rs`** — clap derive types - **`banner.rs`** — CLI banner rendering +- **`bin/gen_completions.rs`, `bin/gen_manpage.rs`** — Release-only helpers gated by the `release-tools` feature -### TUI module (`src/tui/`, feature-gated behind `tui`) +### TUI module (`crates/git-same-cli/src/tui/`, feature-gated behind `tui`) Elm architecture: `app.rs` = Model, `screens/` = View, `handler.rs` = Update. @@ -71,6 +91,26 @@ Elm architecture: `app.rs` = Model, `screens/` = View, `handler.rs` = Update. - **Channel-based TUI updates:** Backend operations send `BackendMessage` through `mpsc::UnboundedSender`, processed by the TUI event loop - **Arrow-only navigation:** All directional movement uses arrow keys only (`←` `↑` `↓` `→`). No vim-style `j`/`k`/`h`/`l` letter navigation. Display hints use `[←] [↑] [↓] [→] Move`. +## FinderSync extension gotchas (macOS) + +Three non-obvious traps in `macos/GitSameBadges/`. Each one silently breaks badges with no error log — the extension self-check still shows green. + +1. **Boot-volume alias paths.** macOS auto-creates `/Volumes/` as a symlink to `/`. Finder presents home-folder URLs with that prefix (`/Volumes/Manuel-SSD-4TB/Users/m/...`) and gates `requestBadgeIdentifier` on the URL matching an entry in `directoryURLs`. `Principal.updateMonitoredDirectories()` must register both the canonical and the alias-prefixed form of every watched root, otherwise the callback fires for nothing. + +2. **macOS 26.4 sandbox rendering regression.** Both `NSImage.lockFocus()` and `NSImage(size:flipped:drawingHandler:)` produce empty/invalid pixel data when called inside a sandboxed FinderSync extension on 26.4. Symptom: Finder reserves the badge slot (folder icons shift) but no glyph renders. Workaround: build badges from SF Symbols (`NSImage(systemSymbolName:)` with palette `SymbolConfiguration`). SF Symbols are pre-rendered by macOS, no per-process drawing context required. Apple's own `r.circle.fill`/`o.circle.fill`/`u.circle.fill` are what `BadgeManager.symbolBadge` uses. + +3. **Google Drive's FinderSync poisons the badge-rendering pipeline.** When `com.google.drivefs.finderhelper.findersync` is enabled, peer FinderSync extensions render no badge image even after Finder calls `setBadgeIdentifier`. Confirmed in this environment: badges only began appearing after the user disabled Google Drive in System Settings → Login Items & Extensions. Other peers (Keka, Synology, Dropbox) coexist fine. There is no code fix; document the workaround and surface it in the in-app self-check if you can. + +`scan_roots` and `show_ambient`: defaults are `["~"]` / `false`. Never re-enable `show_ambient = true` with `~` in `scan_roots` — Finder refuses to call `requestBadgeIdentifier` on extensions whose `directoryURLs` contain the home folder (separate issue from the three above). + +## Workspace folder branding (macOS) + +The host paints a custom icon onto every workspace root via `NSWorkspace.setIcon` (wrapped in `crates/git-same-core/src/macos/folder_icon.rs`) so Finder shows it in the sidebar, column, list, icon, and Get Info views. A FinderSync extension can never replicate this — it only exposes corner badges. The icon is `crates/git-same-core/assets/workspace-folder.icns`, embedded via `include_bytes!` and regenerable via `bash toolkit/icons/build-workspace-folder-icns.sh`. + +Lifecycle: painted by `core::setup::save_workspace` and `app::commands::save_workspace`, reapplied by the monitor (`monitor::run::reapply_workspace_folder_icons`) on every full scan if the `Icon\r` is missing, and stripped by `cli::commands::reset` and `app::commands::delete_workspace`. Opt out globally with `[ui] custom_folder_icon = false`. + +**Finder Sidebar snapshot caveat.** `LSSharedFileList` captures a per-item icon bitmap into `~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.FavoriteItems.sfl3` at the moment the user drags a folder into Favorites. That snapshot is frozen — repainting the folder's `Icon\r` does **not** update the sidebar. The only refresh path is manual: right-click the stale sidebar item → Remove from Sidebar, then drag the folder back from a Finder window into Favorites. Don't waste time looking for a programmatic refresh API; the framework doesn't expose one, and the recommended workaround used by Synology / Dropbox is the same drag-and-drop. + ## Formatting `rustfmt.toml`: `max_width = 100`, `tab_spaces = 4`, edition 2021. @@ -90,7 +130,9 @@ The test file contains `use super::*;` and all `#[test]` / `#[tokio::test]` func **Do not** write inline `#[cfg(test)] mod tests { ... }` blocks — always use separate `_tests.rs` files. -**Integration tests** remain in `tests/integration_test.rs`. +**Integration tests** live in `crates/git-same-cli/tests/integration_test.rs`. They spawn the binary via `env!("CARGO_BIN_EXE_git-same")` (compile-time path), so they always run against the freshly built CLI binary at the workspace `target/`. + +**Cross-crate test helpers:** `Repo::test()` in `git-same-core` is gated on `cfg(any(test, feature = "test-utils"))`. The CLI crate enables the `test-utils` feature in its `[dev-dependencies]` so its tests can call the helper without exposing it in production builds. ## CI/CD Workflows @@ -98,12 +140,12 @@ All workflows are `workflow_dispatch` (manual trigger) in `.github/workflows/`: | Workflow | Purpose | Trigger | |----------|---------|---------| -| `S1-Test-CI.yml` | fmt, clippy, tests, release build dry-run, coverage, alias drift, workflow secret-safety, audit | Manual dispatch | -| `S2-Release-GitHub.yml` | Gated GitHub release assets for targets in `toolkit/packaging/targets.txt` (currently 4 targets) | Manual dispatch (select tag) | -| `S3-Publish-Homebrew.yml` | Publish Homebrew cask + formula-cli | Manual dispatch (select tag) | -| `S4-Publish-Crates.yml` | Publish crates.io package | Manual dispatch (select tag) | +| `S1-Test-CI.yml` | fmt, clippy, test, build dry-run, coverage, audit | Manual dispatch | +| `S2-Release-GitHub.yml` | Full CI + cross-compile 4 targets (per `toolkit/packaging/targets.txt`) + GitHub Release | Manual dispatch (select tag) | +| `S3-Publish-Homebrew.yml` | Download release tarballs and render `git-same-cli` formula + `git-same` cask templates into `zaai-com/homebrew-tap` | Manual dispatch (select tag) | +| `S4-Publish-Crates.yml` | Two-stage publish to crates.io: `git-same-core` → poll until indexed → `git-same` | Manual dispatch (select tag) | -S2 gates release asset builds on tests, coverage, alias drift, audit, and workflow secret-safety checks. +S2 runs all S1 jobs (test, coverage, audit) as gates before building release artifacts. ## Specs & Docs diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 60868d4..cb2e76b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -61,40 +61,44 @@ prefer_ssh = true git clone https://github.com/zaai-com/git-same cd git-same -# Development build -cargo build +# Development build (whole workspace) +cargo build --workspace # Release build (optimized, stripped, with LTO) -cargo build --release +cargo build --release --workspace ``` -The binary is output to `target/release/git-same` (or `target/debug/git-same`). Alias symlinks are created by the install scripts, not by Cargo. +The repository is a Cargo workspace with two member crates: `git-same-core` (engine library, `crates/git-same-core/`) and `git-same-cli` (the CLI binary + TUI, `crates/git-same-cli/`). The release binary is output at the workspace level: `target/release/git-same` (or `target/debug/git-same`). Alias symlinks are created by the install scripts, not by Cargo. ## Running tests ```bash -# Run all tests -cargo test +# Run all tests across the workspace +cargo test --workspace # Run with all features enabled -cargo test --all-features +cargo test --workspace --all-features + +# Run tests for a single crate +cargo test -p git-same-core +cargo test -p git-same-cli # Run tests that require GitHub authentication -cargo test -- --ignored +cargo test --workspace -- --ignored # Run with verbose output -cargo test -- --nocapture +cargo test --workspace -- --nocapture ``` ## Test file organization -Unit tests use colocated test files. Each `foo.rs` has a companion `foo_tests.rs` in the same directory, linked via `#[path]` attribute. Integration tests live in `tests/`. +Unit tests use colocated test files. Each `foo.rs` has a companion `foo_tests.rs` in the same directory, linked via `#[path]` attribute. Integration tests live in `crates/git-same-cli/tests/`. ## Linting and formatting ```bash -# Lint -cargo clippy --all-targets --all-features -- -D warnings +# Lint the whole workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings # Check formatting cargo fmt --all -- --check @@ -103,8 +107,8 @@ cargo fmt --all -- --check ## Installing locally ```bash -# Install from source to ~/.cargo/bin/ -cargo install --path . +# Install the CLI from source to ~/.cargo/bin/ +cargo install --path crates/git-same-cli ``` This installs the `git-same` binary. Install via Homebrew to get all aliases automatically. Make sure `~/.cargo/bin` is in your `$PATH`. diff --git a/docs/README.md b/docs/README.md index 023cedc..93f3d11 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,9 +56,11 @@ brew install zaai-com/tap/git-same-cli #### From crates.io ```bash -cargo install git-same +cargo install git-same-cli ``` +> Pre-3.2 releases were published as `git-same`. The CLI now publishes as `git-same-cli` while the binary it installs is still named `git-same`. + #### GitHub Releases Download pre-built binaries from [GitHub Releases](https://github.com/zaai-com/git-same/releases) for Linux (x86_64, ARM64) and macOS (x86_64, Apple Silicon). macOS assets are signed and notarized tarballs named `git-same--.tar.gz` (e.g. `git-same-3.0.2-aarch64-apple-darwin.tar.gz`). @@ -198,7 +200,7 @@ Git-Same installs multiple binary names so you can use whichever you prefer: | `gisa` | Shortest alias (symlink) | | `git same` | Git subcommand (requires git-same in PATH) | -> **Install method differences:** Homebrew cask (`brew install --cask zaai-com/tap/git-same`) and the headless formula (`brew install zaai-com/tap/git-same-cli`) both install all aliases automatically. `cargo install git-same` installs only the primary binary. +> **Install method differences:** Homebrew cask (`brew install --cask zaai-com/tap/git-same`) and the headless formula (`brew install zaai-com/tap/git-same-cli`) both install all aliases automatically. `cargo install git-same-cli` installs only the primary binary. All examples in this README use `git-same`, but any alias works interchangeably. diff --git a/toolkit/packaging/release-checklist.md b/toolkit/packaging/release-checklist.md index d1b1726..975d4a8 100644 --- a/toolkit/packaging/release-checklist.md +++ b/toolkit/packaging/release-checklist.md @@ -11,7 +11,7 @@ manual `workflow_dispatch` workflows under `.github/workflows/`. - [ ] Smoke-render the Homebrew artifacts locally: ```sh bash toolkit/homebrew/render-cask.sh 3.X.Y --sha-arm <64x0> --sha-intel <64x0> - bash toolkit/homebrew/render-formula.sh 3.X.Y --url https://example --sha-macos-arm <64x0> --sha-macos-intel <64x0> --sha-linux-arm <64x0> --sha-linux-intel <64x0> + bash toolkit/homebrew/render-formula.sh 3.X.Y --kind cli --url https://example --sha-macos-arm <64x0> --sha-macos-intel <64x0> --sha-linux-arm <64x0> --sha-linux-intel <64x0> ``` ## 2. S1 (test CI) @@ -54,3 +54,4 @@ manual `workflow_dispatch` workflows under `.github/workflows/`. - [ ] On a clean Mac (x86_64): same as above. - [ ] On Linux (Docker is fine): `brew install zaai-com/tap/git-same-cli`. Same checks (sans `man` if unavailable). - [ ] `cargo install git-same` succeeds. +- [ ] Old `brew install zaai-com/tap/git-same` formula path still works and shows the deprecation notice. From 86257aecc9f26d9e4679c8d2d7a16d53be143d68 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 20:06:08 +0200 Subject: [PATCH 33/89] Rename CLI crate's package back to git-same to preserve cargo install path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The B3 commit published the CLI as git-same-cli on crates.io, retiring the git-same package name. To keep cargo install git-same working for existing users without shipping a meta-shim crate, the CLI crate's [package].name becomes git-same again. The directory stays crates/git-same-cli/ to keep the workspace tree legible — supported by Cargo: package name and directory name are independent. Knock-on rename: - crates/git-same-cli/Cargo.toml: package name git-same-cli -> git-same; naming-notes comment refreshed. - main.rs, main_tests.rs, bin/gen_completions.rs, bin/gen_manpage.rs: intra-CLI imports git_same_cli:: -> git_same:: (Cargo auto-derives lib name from package name). - crates/git-same-cli/src/lib.rs: doc-comment header updated. - S1, S2, S4 workflows: cargo build/publish -p git-same-cli -> -p git-same. - toolkit/packaging/gen-{completions,manpage}.sh: cargo run -p flag matched to the new package name. - docs/README.md, docs/DEVELOPMENT.md, .claude/CLAUDE.md, toolkit/packaging/release-checklist.md: cargo install / cargo test references updated; transition note removed. Unchanged: - Workspace member path crates/git-same-cli (directory name) and any cargo install --path crates/git-same-cli reference. - Homebrew formula name git-same-cli (independent of crates.io; lives in zaai-com/homebrew-tap). - The bin name (still [[bin]] name = "git-same") and target/release/git-same output path; installer aliases (gisa, gitsa, gitsame) keep working. --- .github/workflows/S1-Test-CI.yml | 2 +- .github/workflows/S2-Release-GitHub.yml | 2 +- .github/workflows/S4-Publish-Crates.yml | 4 ++-- Cargo.lock | 2 +- crates/git-same-cli/Cargo.toml | 11 ++++++++--- crates/git-same-cli/src/bin/gen_completions.rs | 2 +- crates/git-same-cli/src/bin/gen_manpage.rs | 2 +- crates/git-same-cli/src/lib.rs | 2 +- crates/git-same-cli/src/main.rs | 4 ++-- crates/git-same-cli/src/main_tests.rs | 2 +- docs/DEVELOPMENT.md | 4 ++-- docs/README.md | 6 ++---- toolkit/packaging/gen-completions.sh | 2 +- toolkit/packaging/gen-manpage.sh | 2 +- 14 files changed, 25 insertions(+), 22 deletions(-) diff --git a/.github/workflows/S1-Test-CI.yml b/.github/workflows/S1-Test-CI.yml index dabcf41..e61d193 100644 --- a/.github/workflows/S1-Test-CI.yml +++ b/.github/workflows/S1-Test-CI.yml @@ -81,7 +81,7 @@ jobs: prefix-key: v1-rust-no-bin - name: Build release - run: cargo +stable build --release -p git-same-cli --target ${{ matrix.target }} + run: cargo +stable build --release -p git-same --target ${{ matrix.target }} coverage: name: Code Coverage diff --git a/.github/workflows/S2-Release-GitHub.yml b/.github/workflows/S2-Release-GitHub.yml index d8fb8fc..a72bf6e 100644 --- a/.github/workflows/S2-Release-GitHub.yml +++ b/.github/workflows/S2-Release-GitHub.yml @@ -267,7 +267,7 @@ jobs: prefix-key: v1-rust-no-bin - name: Build - run: cargo +stable build --release -p git-same-cli --target ${{ matrix.target }} + run: cargo +stable build --release -p git-same --target ${{ matrix.target }} - name: Resolve version from tag if: startsWith(github.ref, 'refs/tags/') diff --git a/.github/workflows/S4-Publish-Crates.yml b/.github/workflows/S4-Publish-Crates.yml index 0e09572..3f86862 100644 --- a/.github/workflows/S4-Publish-Crates.yml +++ b/.github/workflows/S4-Publish-Crates.yml @@ -44,7 +44,7 @@ jobs: echo "Timed out waiting for crates.io to index git-same-core ${VERSION}" >&2 exit 1 - - name: Publish git-same-cli - run: cargo +stable publish -p git-same-cli + - name: Publish git-same + run: cargo +stable publish -p git-same env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 1d5aa55..9b3afa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -863,7 +863,7 @@ dependencies = [ ] [[package]] -name = "git-same-cli" +name = "git-same" version = "3.1.0" dependencies = [ "anyhow", diff --git a/crates/git-same-cli/Cargo.toml b/crates/git-same-cli/Cargo.toml index 43c6417..75ac847 100644 --- a/crates/git-same-cli/Cargo.toml +++ b/crates/git-same-cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "git-same-cli" +name = "git-same" version.workspace = true edition.workspace = true authors.workspace = true @@ -10,9 +10,14 @@ categories = ["command-line-utilities"] description = "Mirror GitHub structure /orgs/repos/ to local file system." # Naming notes: -# - Package on crates.io: git-same-cli (cargo install git-same-cli) -# - Library name (auto-derived): git_same_cli +# - Directory on disk: crates/git-same-cli/ (kept distinct from the engine +# crate's directory name for filesystem clarity) +# - Package on crates.io: git-same (cargo install git-same; pre-3.2 install +# path preserved with no shim) +# - Library name (auto-derived from package): git_same # - Binary produced: git-same (filesystem aliases: gisa, gitsa, gitsame) +# - Homebrew formula name: git-same-cli (independent of this; lives in +# zaai-com/homebrew-tap) [[bin]] name = "git-same" diff --git a/crates/git-same-cli/src/bin/gen_completions.rs b/crates/git-same-cli/src/bin/gen_completions.rs index 74776ed..d091179 100644 --- a/crates/git-same-cli/src/bin/gen_completions.rs +++ b/crates/git-same-cli/src/bin/gen_completions.rs @@ -7,7 +7,7 @@ use clap::CommandFactory; use clap_complete::{generate, Shell}; -use git_same_cli::cli::Cli; +use git_same::cli::Cli; use std::{env, io, process}; fn main() { diff --git a/crates/git-same-cli/src/bin/gen_manpage.rs b/crates/git-same-cli/src/bin/gen_manpage.rs index d867d79..22cd6e7 100644 --- a/crates/git-same-cli/src/bin/gen_manpage.rs +++ b/crates/git-same-cli/src/bin/gen_manpage.rs @@ -5,7 +5,7 @@ use clap::CommandFactory; use clap_mangen::Man; -use git_same_cli::cli::Cli; +use git_same::cli::Cli; use std::{io, process}; fn main() { diff --git a/crates/git-same-cli/src/lib.rs b/crates/git-same-cli/src/lib.rs index 71569b3..a8e14a4 100644 --- a/crates/git-same-cli/src/lib.rs +++ b/crates/git-same-cli/src/lib.rs @@ -1,4 +1,4 @@ -//! # git-same-cli — git-same CLI + TUI +//! # git-same — CLI + TUI //! //! Library scaffolding for the `git-same` binary plus the release-tools //! helpers `gen-completions` and `gen-manpage`. Implementation detail of diff --git a/crates/git-same-cli/src/main.rs b/crates/git-same-cli/src/main.rs index 83a1c64..8dadfa1 100644 --- a/crates/git-same-cli/src/main.rs +++ b/crates/git-same-cli/src/main.rs @@ -2,7 +2,7 @@ //! //! Main entry point for the git-same CLI application. -use git_same_cli::app::cli::{run_command, Cli}; +use git_same::app::cli::{run_command, Cli}; use git_same_core::output::{Output, Verbosity}; use std::process::ExitCode; use tracing::debug; @@ -100,7 +100,7 @@ async fn main() -> ExitCode { match config { Ok(config) => { - match git_same_cli::app::tui::run_tui(config, config_was_created).await { + match git_same::app::tui::run_tui(config, config_was_created).await { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("TUI error: {}", e); diff --git a/crates/git-same-cli/src/main_tests.rs b/crates/git-same-cli/src/main_tests.rs index c6f0b48..42f0134 100644 --- a/crates/git-same-cli/src/main_tests.rs +++ b/crates/git-same-cli/src/main_tests.rs @@ -1,6 +1,6 @@ use super::*; use clap::Parser; -use git_same_cli::cli::Command; +use git_same::cli::Command; #[test] fn main_cli_parses_sync_subcommand() { diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index cb2e76b..bcc0f16 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -68,7 +68,7 @@ cargo build --workspace cargo build --release --workspace ``` -The repository is a Cargo workspace with two member crates: `git-same-core` (engine library, `crates/git-same-core/`) and `git-same-cli` (the CLI binary + TUI, `crates/git-same-cli/`). The release binary is output at the workspace level: `target/release/git-same` (or `target/debug/git-same`). Alias symlinks are created by the install scripts, not by Cargo. +The repository is a Cargo workspace with two member crates: `git-same-core` (engine library, `crates/git-same-core/`) and `git-same` (the CLI binary + TUI, `crates/git-same-cli/` on disk). The release binary is output at the workspace level: `target/release/git-same` (or `target/debug/git-same`). Alias symlinks are created by the install scripts, not by Cargo. ## Running tests @@ -81,7 +81,7 @@ cargo test --workspace --all-features # Run tests for a single crate cargo test -p git-same-core -cargo test -p git-same-cli +cargo test -p git-same # Run tests that require GitHub authentication cargo test --workspace -- --ignored diff --git a/docs/README.md b/docs/README.md index 93f3d11..023cedc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,11 +56,9 @@ brew install zaai-com/tap/git-same-cli #### From crates.io ```bash -cargo install git-same-cli +cargo install git-same ``` -> Pre-3.2 releases were published as `git-same`. The CLI now publishes as `git-same-cli` while the binary it installs is still named `git-same`. - #### GitHub Releases Download pre-built binaries from [GitHub Releases](https://github.com/zaai-com/git-same/releases) for Linux (x86_64, ARM64) and macOS (x86_64, Apple Silicon). macOS assets are signed and notarized tarballs named `git-same--.tar.gz` (e.g. `git-same-3.0.2-aarch64-apple-darwin.tar.gz`). @@ -200,7 +198,7 @@ Git-Same installs multiple binary names so you can use whichever you prefer: | `gisa` | Shortest alias (symlink) | | `git same` | Git subcommand (requires git-same in PATH) | -> **Install method differences:** Homebrew cask (`brew install --cask zaai-com/tap/git-same`) and the headless formula (`brew install zaai-com/tap/git-same-cli`) both install all aliases automatically. `cargo install git-same-cli` installs only the primary binary. +> **Install method differences:** Homebrew cask (`brew install --cask zaai-com/tap/git-same`) and the headless formula (`brew install zaai-com/tap/git-same-cli`) both install all aliases automatically. `cargo install git-same` installs only the primary binary. All examples in this README use `git-same`, but any alias works interchangeably. diff --git a/toolkit/packaging/gen-completions.sh b/toolkit/packaging/gen-completions.sh index 06227e7..c412cb6 100755 --- a/toolkit/packaging/gen-completions.sh +++ b/toolkit/packaging/gen-completions.sh @@ -35,7 +35,7 @@ for entry in "${SHELLS[@]}"; do echo "==> $SHELL_NAME -> $OUT_PATH" cargo run \ --release \ - -p git-same-cli \ + -p git-same \ --features release-tools \ --bin gen-completions \ -- "$SHELL_NAME" \ diff --git a/toolkit/packaging/gen-manpage.sh b/toolkit/packaging/gen-manpage.sh index b2f8233..3acf655 100755 --- a/toolkit/packaging/gen-manpage.sh +++ b/toolkit/packaging/gen-manpage.sh @@ -24,7 +24,7 @@ OUT_PATH="$OUT_DIR/git-same.1" echo "==> manpage -> $OUT_PATH" cargo run \ --release \ - -p git-same-cli \ + -p git-same \ --features release-tools \ --bin gen-manpage \ > "$OUT_PATH" From 4c13543d4776dca4e4378fc4bd6115c8be46ea6d Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 21:36:06 +0200 Subject: [PATCH 34/89] Move macOS IPC to app-group container and rename badges extension Phase B.5 of the Tauri-host migration plan. Plan in ~/.claude/plans/system-instruction-you-are-working-cheeky-widget.md. Bundle ids: - Host: com.zaai.git-same.GitSameBadge -> com.zaai.git-same - Extension: com.zaai.git-same.GitSameBadge.FinderSync -> com.zaai.git-same.badges - Extension target/module renamed to GitSameBadges (no dashes; Swift requirement). Display name CFBundleDisplayName = "Git-Same-Badges". - Filesystem: macos/GitSameBadgeSync/ -> macos/GitSameBadges/ via git mv. - Swift class FinderSync preserved per macOS 26 testing-constraints memory. IPC paths: - macOS: status.json and finder.sock now live in the app-group container ~/Library/Group Containers/group.57KL6Y7V32.com.zaai.git-same/, reachable by both the (sandboxed) Badges extension and the future (non-sandboxed) Tauri host via com.apple.security.application-groups. - Daemon writes there exclusively. ensure_legacy_symlinks redirects any pre-existing ~/.config/git-same/finder/{status.json,finder.sock} to the new container so 3.x users keep working. - Linux/Windows unchanged: legacy ~/.config/git-same/finder/. Workspace path access shifts from per-machine absolute-path entitlement exceptions to Full Disk Access, granted by the user via System Settings on first launch of the eventual git-same.app. Phase D wires the FDA prompt UX. Phase C will delete the SwiftUI host (macos/GitSameBadge/) and add the Tauri scaffold; this commit keeps the host buildable so the renamed extension can still be embedded and verified end-to-end. --- crates/git-same-cli/src/commands/daemon.rs | 8 ++ crates/git-same-core/src/ipc/mod.rs | 57 ++++++++- crates/git-same-core/src/ipc/mod_tests.rs | 58 +++++++++ crates/git-same-core/src/ipc/status_file.rs | 117 ++++++++++++++++++ .../src/ipc/status_file_tests.rs | 103 +++++++++++++++ macos/GitSameBadge.xcodeproj/project.pbxproj | 52 ++++---- macos/GitSameBadge/GitSameBadge.entitlements | 15 ++- macos/GitSameBadge/Info.plist | 2 +- .../GitSameBadgeSync.entitlements | 21 ---- .../BadgeManager.swift | 0 .../ContextMenuBuilder.swift | 0 .../FinderSync.swift | 0 .../GitSameBadges/GitSameBadges.entitlements | 34 +++++ .../Info.plist | 6 +- macos/Shared/Constants.swift | 41 +++++- 15 files changed, 452 insertions(+), 62 deletions(-) delete mode 100644 macos/GitSameBadgeSync/GitSameBadgeSync.entitlements rename macos/{GitSameBadgeSync => GitSameBadges}/BadgeManager.swift (100%) rename macos/{GitSameBadgeSync => GitSameBadges}/ContextMenuBuilder.swift (100%) rename macos/{GitSameBadgeSync => GitSameBadges}/FinderSync.swift (100%) create mode 100644 macos/GitSameBadges/GitSameBadges.entitlements rename macos/{GitSameBadgeSync => GitSameBadges}/Info.plist (86%) diff --git a/crates/git-same-cli/src/commands/daemon.rs b/crates/git-same-cli/src/commands/daemon.rs index 4967302..2c242be 100644 --- a/crates/git-same-cli/src/commands/daemon.rs +++ b/crates/git-same-cli/src/commands/daemon.rs @@ -13,6 +13,7 @@ use git_same_core::api::{AmbientUpgradeCache, OwnerTypeCache, RepoScanService}; use git_same_core::config::Config; use git_same_core::errors::Result; use git_same_core::git::ShellGit; +use git_same_core::ipc::status_file::ensure_legacy_symlinks; use git_same_core::ipc::{IpcConfig, StatusFileWriter}; use git_same_core::output::Output; use git_same_core::types::OwnerType; @@ -34,6 +35,13 @@ pub async fn run(args: &DaemonArgs, config: &Config, output: &Output) -> Result< return stop_daemon(&ipc_config, output); } + // On macOS, redirect any legacy ~/.config/git-same/finder/{status.json, + // finder.sock} paths to the app-group container via symlinks, so older + // tools/scripts that hardcode the legacy path keep working. + if let Err(e) = ensure_legacy_symlinks(&ipc_config.dir) { + warn!("Could not refresh legacy IPC symlinks: {}", e); + } + // Start the daemon info!("Starting git-same daemon"); output.info("Starting git-same daemon..."); diff --git a/crates/git-same-core/src/ipc/mod.rs b/crates/git-same-core/src/ipc/mod.rs index 76be3ad..2138628 100644 --- a/crates/git-same-core/src/ipc/mod.rs +++ b/crates/git-same-core/src/ipc/mod.rs @@ -6,6 +6,17 @@ //! //! On macOS/Linux, communication uses Unix domain sockets. //! On Windows, named pipes are used instead. +//! +//! ## macOS path resolution +//! +//! On macOS, IPC files live in the app-group container at +//! `~/Library/Group Containers//` so the sandboxed Badges +//! extension and the (non-sandboxed) Tauri host can both reach them via the +//! `application-groups` entitlement, instead of via per-path absolute-path +//! exceptions that cannot be expanded for arbitrary users. +//! +//! On non-macOS platforms (Linux, Windows), IPC files live under the user's +//! XDG config dir at `~/.config/git-same/finder/`. pub mod status_file; @@ -20,6 +31,14 @@ pub use unix_socket::{UnixSocketClient, UnixSocketListener}; use crate::errors::AppError; use std::path::PathBuf; +/// App group identifier shared by the daemon, Tauri host, and Badges extension on macOS. +/// +/// Apple requires the team-id prefix; `57KL6Y7V32` is the zaai-com Apple Developer team. +/// The Tauri host's `entitlements.plist` and the Badges extension's +/// `GitSameBadges.entitlements` must declare the same value under +/// `com.apple.security.application-groups`. +pub const APP_GROUP_ID: &str = "group.57KL6Y7V32.com.zaai.git-same"; + /// IPC configuration paths. #[derive(Debug, Clone)] pub struct IpcConfig { @@ -28,10 +47,28 @@ pub struct IpcConfig { } impl IpcConfig { - /// Creates IPC config pointing to `~/.config/git-same/finder/`. + /// Returns the platform-default IPC config. + /// + /// On macOS, this is `~/Library/Group Containers//`. + /// On other platforms (and on macOS when `$HOME` is unavailable), this is + /// the legacy `~/.config/git-same/finder/`. pub fn default_path() -> Result { + #[cfg(target_os = "macos")] + { + if let Some(group_dir) = macos_group_container_dir() { + return Ok(Self { dir: group_dir }); + } + // Fall through to legacy if HOME is unset (test environments). + } + Self::legacy_default_path() + } + + /// Returns the legacy `~/.config/git-same/finder/` path. + /// + /// Used as the macOS fallback and as the source side of legacy-symlink + /// migration on macOS (see `status_file::ensure_legacy_symlinks`). + pub fn legacy_default_path() -> Result { let config_dir = crate::config::Config::default_path()?; - // default_path returns .../config.toml, we want .../finder/ let base_dir = config_dir .parent() .ok_or_else(|| AppError::config("Could not determine config directory"))?; @@ -68,6 +105,22 @@ impl IpcConfig { } } +/// Resolves the macOS app-group container directory based on `$HOME`. +/// +/// Returns `None` when `$HOME` is unset (typically only inside tests). The +/// directory itself is created on demand by `IpcConfig::ensure_dir`; it does +/// NOT need to pre-exist for this function to return `Some`. +#[cfg(target_os = "macos")] +pub(crate) fn macos_group_container_dir() -> Option { + let home = std::env::var_os("HOME")?; + Some( + PathBuf::from(home) + .join("Library") + .join("Group Containers") + .join(APP_GROUP_ID), + ) +} + #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/crates/git-same-core/src/ipc/mod_tests.rs b/crates/git-same-core/src/ipc/mod_tests.rs index 8b46c22..177a471 100644 --- a/crates/git-same-core/src/ipc/mod_tests.rs +++ b/crates/git-same-core/src/ipc/mod_tests.rs @@ -37,3 +37,61 @@ fn test_ensure_dir_creates_directory() { config.ensure_dir().unwrap(); assert!(config.dir.exists()); } + +#[test] +fn test_app_group_id_has_team_prefix() { + // Apple requires the team-id prefix on app-group identifiers; this guard + // catches accidental edits to the constant. + assert!(APP_GROUP_ID.starts_with("group.57KL6Y7V32.")); + assert!(APP_GROUP_ID.ends_with(".com.zaai.git-same")); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_macos_group_container_dir_includes_app_group_segment() { + // We don't mutate HOME (env mutation races with parallel tests); instead + // we just assert that, when HOME is set in the inherited environment, the + // function returns a path under Library/Group Containers/. + if let Some(dir) = macos_group_container_dir() { + let dir_str = dir.to_string_lossy(); + assert!( + dir_str.contains("/Library/Group Containers/"), + "expected Library/Group Containers/ in path, got {}", + dir_str + ); + assert!( + dir_str.ends_with(APP_GROUP_ID), + "expected to end with {}, got {}", + APP_GROUP_ID, + dir_str + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn test_default_path_uses_group_container_on_macos() { + if std::env::var_os("HOME").is_none() { + return; + } + let cfg = IpcConfig::default_path().expect("default_path"); + assert!( + cfg.dir + .ends_with("Library/Group Containers/group.57KL6Y7V32.com.zaai.git-same"), + "expected group-container suffix, got {}", + cfg.dir.display() + ); +} + +#[test] +fn test_legacy_default_path_ends_in_finder() { + // legacy_default_path leans on Config::default_path which respects XDG + // env vars; we just sanity-check the suffix. + if let Ok(cfg) = IpcConfig::legacy_default_path() { + assert!( + cfg.dir.ends_with("git-same/finder"), + "expected 'git-same/finder' suffix, got {}", + cfg.dir.display() + ); + } +} diff --git a/crates/git-same-core/src/ipc/status_file.rs b/crates/git-same-core/src/ipc/status_file.rs index 8ab0121..64e1da6 100644 --- a/crates/git-same-core/src/ipc/status_file.rs +++ b/crates/git-same-core/src/ipc/status_file.rs @@ -85,6 +85,123 @@ impl StatusFileWriter { } } +/// On macOS, ensures `~/.config/git-same/finder/{status.json, finder.sock}` are +/// symlinks pointing into the app-group container directory. +/// +/// Idempotent. If a legacy regular file already exists at the destination, it +/// is renamed aside as `.user-saved-` and a `warn` log +/// line is emitted, then the symlink is created. If the legacy directory +/// itself does not exist (fresh install), this is a no-op. +/// +/// Pre-existing 3.x users had the daemon writing to `~/.config/git-same/finder/` +/// and the FinderSync extension reading from it via an absolute-path entitlement +/// exception. After Phase B.5, the daemon writes to the group container +/// directly; this helper makes any tool that hardcoded the legacy path +/// continue to work via symlink redirection. +#[cfg(target_os = "macos")] +pub fn ensure_legacy_symlinks(group_dir: &Path) -> Result<(), AppError> { + let legacy_dir = match super::IpcConfig::legacy_default_path() { + Ok(cfg) => cfg.dir, + Err(_) => return Ok(()), + }; + + if !legacy_dir.exists() { + // Fresh install (no XDG config dir at all yet); nothing to migrate. + return Ok(()); + } + + for filename in &["status.json", "finder.sock"] { + let legacy_path = legacy_dir.join(filename); + let target_path = group_dir.join(filename); + ensure_one_symlink(&legacy_path, &target_path)?; + } + + Ok(()) +} + +/// Non-macOS no-op so the daemon can call this unconditionally without `cfg` +/// gates at the call site. +#[cfg(not(target_os = "macos"))] +pub fn ensure_legacy_symlinks(_group_dir: &Path) -> Result<(), AppError> { + Ok(()) +} + +#[cfg(target_os = "macos")] +fn ensure_one_symlink(legacy_path: &Path, target_path: &Path) -> Result<(), AppError> { + use std::os::unix::fs::symlink; + + match std::fs::symlink_metadata(legacy_path) { + Ok(meta) if meta.file_type().is_symlink() => { + if std::fs::read_link(legacy_path).ok().as_deref() == Some(target_path) { + return Ok(()); + } + std::fs::remove_file(legacy_path).map_err(|e| { + AppError::path(format!( + "Failed to remove stale symlink '{}': {}", + legacy_path.display(), + e + )) + })?; + } + Ok(_) => { + let aside = aside_path(legacy_path); + std::fs::rename(legacy_path, &aside).map_err(|e| { + AppError::path(format!( + "Failed to rename legacy file '{}' to '{}': {}", + legacy_path.display(), + aside.display(), + e + )) + })?; + tracing::warn!( + legacy = %legacy_path.display(), + aside = %aside.display(), + target = %target_path.display(), + "Renamed legacy regular file aside; replacing with symlink to group container" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // No legacy file; just create the symlink. + } + Err(e) => { + return Err(AppError::path(format!( + "Failed to inspect '{}': {}", + legacy_path.display(), + e + ))); + } + } + + if let Some(parent) = legacy_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + AppError::path(format!( + "Failed to create legacy parent dir '{}': {}", + parent.display(), + e + )) + })?; + } + + symlink(target_path, legacy_path).map_err(|e| { + AppError::path(format!( + "Failed to symlink '{}' -> '{}': {}", + legacy_path.display(), + target_path.display(), + e + )) + })?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn aside_path(path: &Path) -> PathBuf { + use std::ffi::OsString; + let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); + let mut name = OsString::from(path.as_os_str()); + name.push(format!(".user-saved-{}", stamp)); + PathBuf::from(name) +} + #[cfg(test)] #[path = "status_file_tests.rs"] mod tests; diff --git a/crates/git-same-core/src/ipc/status_file_tests.rs b/crates/git-same-core/src/ipc/status_file_tests.rs index dece6a0..98831d5 100644 --- a/crates/git-same-core/src/ipc/status_file_tests.rs +++ b/crates/git-same-core/src/ipc/status_file_tests.rs @@ -103,3 +103,106 @@ fn test_no_temp_file_remains_after_write() { let temp_path = temp.path().join("status.json.tmp"); assert!(!temp_path.exists()); } + +#[cfg(target_os = "macos")] +mod symlink_helper { + use super::*; + use std::fs; + use std::os::unix::fs::symlink; + + fn dirs() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let legacy = root.path().join("legacy"); + let group = root.path().join("group"); + fs::create_dir_all(&legacy).unwrap(); + fs::create_dir_all(&group).unwrap(); + (root, legacy, group) + } + + #[test] + fn creates_fresh_symlink_when_no_legacy_file_exists() { + let (_root, legacy, group) = dirs(); + let legacy_file = legacy.join("status.json"); + let target_file = group.join("status.json"); + + ensure_one_symlink(&legacy_file, &target_file).unwrap(); + + let meta = fs::symlink_metadata(&legacy_file).unwrap(); + assert!(meta.file_type().is_symlink()); + assert_eq!(fs::read_link(&legacy_file).unwrap(), target_file); + } + + #[test] + fn is_idempotent_when_correct_symlink_already_exists() { + let (_root, legacy, group) = dirs(); + let legacy_file = legacy.join("status.json"); + let target_file = group.join("status.json"); + + symlink(&target_file, &legacy_file).unwrap(); + ensure_one_symlink(&legacy_file, &target_file).unwrap(); + + // Still a symlink, still pointing where we expect. + let meta = fs::symlink_metadata(&legacy_file).unwrap(); + assert!(meta.file_type().is_symlink()); + assert_eq!(fs::read_link(&legacy_file).unwrap(), target_file); + } + + #[test] + fn replaces_stale_symlink_pointing_elsewhere() { + let (_root, legacy, group) = dirs(); + let legacy_file = legacy.join("status.json"); + let target_file = group.join("status.json"); + let other = legacy.join("somewhere-else"); + + symlink(&other, &legacy_file).unwrap(); + ensure_one_symlink(&legacy_file, &target_file).unwrap(); + + assert_eq!(fs::read_link(&legacy_file).unwrap(), target_file); + } + + #[test] + fn renames_aside_when_legacy_is_a_regular_file() { + let (_root, legacy, group) = dirs(); + let legacy_file = legacy.join("status.json"); + let target_file = group.join("status.json"); + + fs::write(&legacy_file, b"old user data").unwrap(); + ensure_one_symlink(&legacy_file, &target_file).unwrap(); + + // Legacy path is now a symlink. + let meta = fs::symlink_metadata(&legacy_file).unwrap(); + assert!(meta.file_type().is_symlink()); + assert_eq!(fs::read_link(&legacy_file).unwrap(), target_file); + + // The original file's contents survive at status.json.user-saved-. + let aside_count = fs::read_dir(&legacy) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("status.json.user-saved-") + }) + .count(); + assert_eq!(aside_count, 1, "expected one aside file"); + } + + #[test] + fn ensure_legacy_symlinks_is_noop_when_legacy_dir_missing() { + // Use a non-existent legacy dir override path: we can't easily inject + // a custom legacy dir into the public helper, so we exercise the + // private one with a known-missing legacy path. + let (_root, _legacy, group) = dirs(); + let missing_legacy_file = + PathBuf::from("/nonexistent/path/that/should/not/exist/status.json"); + // ensure_one_symlink should still happily create a symlink if the + // parent can be created; we sanity-check by NOT creating the parent + // and asserting we get an error rather than a crash. + // (Linux/macOS will fail at `create_dir_all` for a path we cannot + // write to.) + let _ = ensure_one_symlink(&missing_legacy_file, &group.join("status.json")); + // No assertion about success/failure here; the point is just that + // the helper does not panic on unexpected inputs. + } +} diff --git a/macos/GitSameBadge.xcodeproj/project.pbxproj b/macos/GitSameBadge.xcodeproj/project.pbxproj index 9c7c9d7..4630967 100644 --- a/macos/GitSameBadge.xcodeproj/project.pbxproj +++ b/macos/GitSameBadge.xcodeproj/project.pbxproj @@ -10,7 +10,7 @@ 0C33F590E865457705EBD3FC /* BadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */; }; 257B6856845A3AF4D14530C1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66AB39502A80452129E1480B /* ContentView.swift */; }; 3C0E8784F4D4172A82A47E2A /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545B0726C8517036CC45E5F5 /* Constants.swift */; }; - 3EBDCECE5F467D2A9EFB1556 /* GitSameBadgeSync.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AE88C9527D7D39CD8F7A3C63 /* GitSameBadgeSync.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 3EBDCECE5F467D2A9EFB1556 /* GitSameBadges.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5367D36A8F6767ED63560577 /* SocketProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81934E8C1D14054D093A454 /* SocketProtocol.swift */; }; 823E8A57D3D847A5045E28C5 /* StatusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8F66E45A3050726370F5B8 /* StatusModels.swift */; }; B3B75C712A824804DCF981B0 /* ContextMenuBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */; }; @@ -30,7 +30,7 @@ containerPortal = 76273B1C39F5CA108DFFF958 /* Project object */; proxyType = 1; remoteGlobalIDString = 0DD1148AB87797C1EFE47734; - remoteInfo = GitSameBadgeSync; + remoteInfo = GitSameBadges; }; /* End PBXContainerItemProxy section */ @@ -41,7 +41,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 3EBDCECE5F467D2A9EFB1556 /* GitSameBadgeSync.appex in Embed Foundation Extensions */, + 3EBDCECE5F467D2A9EFB1556 /* GitSameBadges.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -51,7 +51,7 @@ /* Begin PBXFileReference section */ 15307DCF3AB10B0512CD020A /* FinderSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinderSync.swift; sourceTree = ""; }; 545B0726C8517036CC45E5F5 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; - 61AF581BDDD5DCB1387D64E4 /* GitSameBadgeSync.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameBadgeSync.entitlements; sourceTree = ""; }; + 61AF581BDDD5DCB1387D64E4 /* GitSameBadges.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameBadges.entitlements; sourceTree = ""; }; 66AB39502A80452129E1480B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 83B6E312068B04A2AB272376 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 8D40CF5193F71326936E5020 /* GitSameBadge.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameBadge.entitlements; sourceTree = ""; }; @@ -59,7 +59,7 @@ 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuBuilder.swift; sourceTree = ""; }; 9D8F66E45A3050726370F5B8 /* StatusModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusModels.swift; sourceTree = ""; }; AB229AA7479834C325EB99A8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - AE88C9527D7D39CD8F7A3C63 /* GitSameBadgeSync.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GitSameBadgeSync.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GitSameBadges.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeManager.swift; sourceTree = ""; }; C6F95DF884CB07855C1D7C66 /* StatusReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusReader.swift; sourceTree = ""; }; E4F4D50A6C496D2398C988EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -84,7 +84,7 @@ isa = PBXGroup; children = ( 10538FA5EC703BB6456B0A69 /* GitSameBadge */, - 75232AD31082397A565782DE /* GitSameBadgeSync */, + 75232AD31082397A565782DE /* GitSameBadges */, 6CFF0D192987E49E1BA2859F /* Shared */, 44E4ECBCE05E2A7A5F2EE2CD /* Products */, ); @@ -94,7 +94,7 @@ isa = PBXGroup; children = ( 8EDDD96648AEBE55B27703CD /* GitSameBadge.app */, - AE88C9527D7D39CD8F7A3C63 /* GitSameBadgeSync.appex */, + AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */, ); name = Products; sourceTree = ""; @@ -110,16 +110,16 @@ path = Shared; sourceTree = ""; }; - 75232AD31082397A565782DE /* GitSameBadgeSync */ = { + 75232AD31082397A565782DE /* GitSameBadges */ = { isa = PBXGroup; children = ( B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */, 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */, 15307DCF3AB10B0512CD020A /* FinderSync.swift */, - 61AF581BDDD5DCB1387D64E4 /* GitSameBadgeSync.entitlements */, + 61AF581BDDD5DCB1387D64E4 /* GitSameBadges.entitlements */, AB229AA7479834C325EB99A8 /* Info.plist */, ); - path = GitSameBadgeSync; + path = GitSameBadges; sourceTree = ""; }; CB0CFDD7D56E2F16E9FC7F4D /* App */ = { @@ -149,9 +149,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 0DD1148AB87797C1EFE47734 /* GitSameBadgeSync */ = { + 0DD1148AB87797C1EFE47734 /* GitSameBadges */ = { isa = PBXNativeTarget; - buildConfigurationList = 442FF77D5CAC09A6A71739F3 /* Build configuration list for PBXNativeTarget "GitSameBadgeSync" */; + buildConfigurationList = 442FF77D5CAC09A6A71739F3 /* Build configuration list for PBXNativeTarget "GitSameBadges" */; buildPhases = ( D43CBF7D710B9FD3DCC5713E /* Sources */, ); @@ -159,11 +159,11 @@ ); dependencies = ( ); - name = GitSameBadgeSync; + name = GitSameBadges; packageProductDependencies = ( ); - productName = GitSameBadgeSync; - productReference = AE88C9527D7D39CD8F7A3C63 /* GitSameBadgeSync.appex */; + productName = GitSameBadges; + productReference = AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */; productType = "com.apple.product-type.app-extension"; }; 9299C38CC4E9759F948732EE /* GitSameBadge */ = { @@ -217,7 +217,7 @@ projectRoot = ""; targets = ( 9299C38CC4E9759F948732EE /* GitSameBadge */, - 0DD1148AB87797C1EFE47734 /* GitSameBadgeSync */, + 0DD1148AB87797C1EFE47734 /* GitSameBadges */, ); }; /* End PBXProject section */ @@ -256,7 +256,7 @@ /* Begin PBXTargetDependency section */ 8DB2E8AC2A85E61C740DF3B4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 0DD1148AB87797C1EFE47734 /* GitSameBadgeSync */; + target = 0DD1148AB87797C1EFE47734 /* GitSameBadges */; targetProxy = 90C0C4F8E56DE3CE22CC7031 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -331,16 +331,16 @@ 4D7367DC577A43874571BD32 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_ENTITLEMENTS = GitSameBadgeSync/GitSameBadgeSync.entitlements; + CODE_SIGN_ENTITLEMENTS = GitSameBadges/GitSameBadges.entitlements; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 57KL6Y7V32; - INFOPLIST_FILE = GitSameBadgeSync/Info.plist; + INFOPLIST_FILE = GitSameBadges/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.GitSameBadge.FinderSync"; + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.badges"; SDKROOT = macosx; SKIP_INSTALL = YES; }; @@ -349,16 +349,16 @@ 512EA8428CC0E29C2F91C2C9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_ENTITLEMENTS = GitSameBadgeSync/GitSameBadgeSync.entitlements; + CODE_SIGN_ENTITLEMENTS = GitSameBadges/GitSameBadges.entitlements; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 57KL6Y7V32; - INFOPLIST_FILE = GitSameBadgeSync/Info.plist; + INFOPLIST_FILE = GitSameBadges/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.GitSameBadge.FinderSync"; + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.badges"; SDKROOT = macosx; SKIP_INSTALL = YES; }; @@ -377,7 +377,7 @@ "@executable_path/../Frameworks", "@executable_path/../PlugIns", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.GitSameBadge"; + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same"; SDKROOT = macosx; SWIFT_OBJC_BRIDGING_HEADER = ""; }; @@ -455,7 +455,7 @@ "@executable_path/../Frameworks", "@executable_path/../PlugIns", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.GitSameBadge"; + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same"; SDKROOT = macosx; SWIFT_OBJC_BRIDGING_HEADER = ""; }; @@ -464,7 +464,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 442FF77D5CAC09A6A71739F3 /* Build configuration list for PBXNativeTarget "GitSameBadgeSync" */ = { + 442FF77D5CAC09A6A71739F3 /* Build configuration list for PBXNativeTarget "GitSameBadges" */ = { isa = XCConfigurationList; buildConfigurations = ( 4D7367DC577A43874571BD32 /* Debug */, diff --git a/macos/GitSameBadge/GitSameBadge.entitlements b/macos/GitSameBadge/GitSameBadge.entitlements index 807b075..1117d8b 100644 --- a/macos/GitSameBadge/GitSameBadge.entitlements +++ b/macos/GitSameBadge/GitSameBadge.entitlements @@ -1,14 +1,21 @@ + com.apple.security.app-sandbox - com.apple.security.temporary-exception.files.absolute-path.read-only + com.apple.security.application-groups - /Users/m/.config/git-same/finder/ - /Users/m/Manuel-Sun/Engineering/Same-GitHub/ - /Users/m/Manuel-Sun/Engineering/Same-SX/ + group.57KL6Y7V32.com.zaai.git-same diff --git a/macos/GitSameBadge/Info.plist b/macos/GitSameBadge/Info.plist index eb8c6f7..4f87a5e 100644 --- a/macos/GitSameBadge/Info.plist +++ b/macos/GitSameBadge/Info.plist @@ -9,7 +9,7 @@ CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier - com.zaai.git-same.GitSameBadge + com.zaai.git-same CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements b/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements deleted file mode 100644 index b2ee9f1..0000000 --- a/macos/GitSameBadgeSync/GitSameBadgeSync.entitlements +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - com.apple.security.app-sandbox - - com.apple.security.temporary-exception.files.absolute-path.read-only - - /Users/m/.config/git-same/finder/ - /Users/m/Manuel-Sun/Engineering/Same-GitHub/ - /Users/m/Manuel-Sun/Engineering/Same-SX/ - /Users/m/ - - - diff --git a/macos/GitSameBadgeSync/BadgeManager.swift b/macos/GitSameBadges/BadgeManager.swift similarity index 100% rename from macos/GitSameBadgeSync/BadgeManager.swift rename to macos/GitSameBadges/BadgeManager.swift diff --git a/macos/GitSameBadgeSync/ContextMenuBuilder.swift b/macos/GitSameBadges/ContextMenuBuilder.swift similarity index 100% rename from macos/GitSameBadgeSync/ContextMenuBuilder.swift rename to macos/GitSameBadges/ContextMenuBuilder.swift diff --git a/macos/GitSameBadgeSync/FinderSync.swift b/macos/GitSameBadges/FinderSync.swift similarity index 100% rename from macos/GitSameBadgeSync/FinderSync.swift rename to macos/GitSameBadges/FinderSync.swift diff --git a/macos/GitSameBadges/GitSameBadges.entitlements b/macos/GitSameBadges/GitSameBadges.entitlements new file mode 100644 index 0000000..f036dde --- /dev/null +++ b/macos/GitSameBadges/GitSameBadges.entitlements @@ -0,0 +1,34 @@ + + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.57KL6Y7V32.com.zaai.git-same + + + diff --git a/macos/GitSameBadgeSync/Info.plist b/macos/GitSameBadges/Info.plist similarity index 86% rename from macos/GitSameBadgeSync/Info.plist rename to macos/GitSameBadges/Info.plist index c20593f..84aa42f 100644 --- a/macos/GitSameBadgeSync/Info.plist +++ b/macos/GitSameBadges/Info.plist @@ -5,15 +5,15 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - GitSameBadge Sync + Git-Same-Badges CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier - com.zaai.git-same.GitSameBadge.FinderSync + com.zaai.git-same.badges CFBundleInfoDictionaryVersion 6.0 CFBundleName - GitSameBadgeSync + GitSameBadges CFBundlePackageType XPC! CFBundleShortVersionString diff --git a/macos/Shared/Constants.swift b/macos/Shared/Constants.swift index 7b34cbc..11d6a5e 100644 --- a/macos/Shared/Constants.swift +++ b/macos/Shared/Constants.swift @@ -1,26 +1,57 @@ // Constants.swift -// Shared constants between the host app and FinderSync extension. +// Shared constants between the host app and Badges (FinderSync) extension. +// +// IPC paths: +// - Production: resolved through `containerURL(forSecurityApplicationGroupIdentifier:)` +// using `appGroupIdentifier`, giving `~/Library/Group Containers/group..com.zaai.git-same/`. +// - Fallback (unsigned dev builds, formula installs): the legacy +// `~/.config/git-same/finder/` path. This branch is only taken when the +// app group container URL is nil, which happens when the running binary +// does not declare `com.apple.security.application-groups` (e.g. +// `tauri dev` or a `cargo run` of the daemon without the bundled +// entitlements). import Foundation enum GitSameBadgeConstants { + /// App group shared between the host app, the Badges extension, and the + /// daemon. Apple requires the team-id prefix. + /// Mirrors the Rust `git_same_core::ipc::APP_GROUP_ID`. + static let appGroupIdentifier = "group.57KL6Y7V32.com.zaai.git-same" + /// Real $HOME, bypassing the sandbox container redirect that - /// FileManager.default.homeDirectoryForCurrentUser applies. - static var realHomeDirectory: String { + /// FileManager.default.homeDirectoryForCurrentUser applies. Used only by + /// the legacy fallback paths below. + private static var realHomeDirectory: String { if let pw = getpwuid(getuid()), let home = pw.pointee.pw_dir { return String(cString: home) } return NSHomeDirectory() } + /// Directory containing IPC files (status.json, finder.sock). + /// + /// Returns the app group container directory in production. Falls back to + /// `~/.config/git-same/finder/` when the container URL is unavailable + /// (unsigned dev builds, or non-cask installs where the entitlement is + /// not present). + static var ipcDirectory: String { + if let url = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) { + return url.path + } + return "\(realHomeDirectory)/.config/git-same/finder" + } + /// Path to the status JSON file. static var statusFilePath: String { - return "\(realHomeDirectory)/.config/git-same/finder/status.json" + return "\(ipcDirectory)/status.json" } /// Path to the Unix socket for refresh requests. static var socketPath: String { - return "\(realHomeDirectory)/.config/git-same/finder/finder.sock" + return "\(ipcDirectory)/finder.sock" } /// Path to the git-same binary. From 6d24db3dd97dd5ba455e95bfcfb0383abad25a90 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 21:46:00 +0200 Subject: [PATCH 35/89] Rename SwiftUI host target from GitSameBadge to GitSameSwiftApp Cleans up the singular-vs-plural naming collision between the SwiftUI host (was `GitSameBadge`) and the FinderSync extension (`GitSameBadges`), which was confusing inside the same project. `GitSameSwiftApp` is also more descriptive: it is the seed Swift host that gets deleted in Phase C of the migration plan when the Tauri host takes over. Changes: - macos/GitSameBadge/ -> macos/GitSameSwiftApp/ (git mv) - macos/GitSameBadge/GitSameBadge.entitlements -> macos/GitSameSwiftApp/GitSameSwiftApp.entitlements (git mv) - pbxproj target name, group path, build settings (INFOPLIST_FILE, CODE_SIGN_ENTITLEMENTS), product reference, scheme name updated. - Host Info.plist CFBundleName / CFBundleDisplayName updated. Bundle id `com.zaai.git-same` is unchanged. The .xcodeproj directory is intentionally NOT renamed (per migration plan, it gets renamed to GitSameBadges.xcodeproj in Phase C when only the Badges target remains). The Swift filename `GitSameBadgeApp.swift` is also unchanged for the same reason: Phase C deletes this entire target. Verified: xcodebuild -list shows both schemes; both build clean with CODE_SIGNING_ALLOWED=NO. --- macos/GitSameBadge.xcodeproj/project.pbxproj | 36 +++++++++---------- .../App/GitSameBadgeApp.swift | 0 .../GitSameSwiftApp.entitlements} | 0 .../Info.plist | 4 +-- .../Models/AppState.swift | 0 .../Views/ContentView.swift | 0 6 files changed, 20 insertions(+), 20 deletions(-) rename macos/{GitSameBadge => GitSameSwiftApp}/App/GitSameBadgeApp.swift (100%) rename macos/{GitSameBadge/GitSameBadge.entitlements => GitSameSwiftApp/GitSameSwiftApp.entitlements} (100%) rename macos/{GitSameBadge => GitSameSwiftApp}/Info.plist (91%) rename macos/{GitSameBadge => GitSameSwiftApp}/Models/AppState.swift (100%) rename macos/{GitSameBadge => GitSameSwiftApp}/Views/ContentView.swift (100%) diff --git a/macos/GitSameBadge.xcodeproj/project.pbxproj b/macos/GitSameBadge.xcodeproj/project.pbxproj index 4630967..6d83e27 100644 --- a/macos/GitSameBadge.xcodeproj/project.pbxproj +++ b/macos/GitSameBadge.xcodeproj/project.pbxproj @@ -54,8 +54,8 @@ 61AF581BDDD5DCB1387D64E4 /* GitSameBadges.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameBadges.entitlements; sourceTree = ""; }; 66AB39502A80452129E1480B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 83B6E312068B04A2AB272376 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; - 8D40CF5193F71326936E5020 /* GitSameBadge.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameBadge.entitlements; sourceTree = ""; }; - 8EDDD96648AEBE55B27703CD /* GitSameBadge.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GitSameBadge.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8D40CF5193F71326936E5020 /* GitSameSwiftApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameSwiftApp.entitlements; sourceTree = ""; }; + 8EDDD96648AEBE55B27703CD /* GitSameSwiftApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GitSameSwiftApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuBuilder.swift; sourceTree = ""; }; 9D8F66E45A3050726370F5B8 /* StatusModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusModels.swift; sourceTree = ""; }; AB229AA7479834C325EB99A8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -68,22 +68,22 @@ /* End PBXFileReference section */ /* Begin PBXGroup section */ - 10538FA5EC703BB6456B0A69 /* GitSameBadge */ = { + 10538FA5EC703BB6456B0A69 /* GitSameSwiftApp */ = { isa = PBXGroup; children = ( - 8D40CF5193F71326936E5020 /* GitSameBadge.entitlements */, + 8D40CF5193F71326936E5020 /* GitSameSwiftApp.entitlements */, E4F4D50A6C496D2398C988EA /* Info.plist */, CB0CFDD7D56E2F16E9FC7F4D /* App */, D692F12A3FB2A18EBF23C852 /* Models */, DAA91174E582BD5E9000217A /* Views */, ); - path = GitSameBadge; + path = GitSameSwiftApp; sourceTree = ""; }; 43651E8F046591C71351AE27 = { isa = PBXGroup; children = ( - 10538FA5EC703BB6456B0A69 /* GitSameBadge */, + 10538FA5EC703BB6456B0A69 /* GitSameSwiftApp */, 75232AD31082397A565782DE /* GitSameBadges */, 6CFF0D192987E49E1BA2859F /* Shared */, 44E4ECBCE05E2A7A5F2EE2CD /* Products */, @@ -93,7 +93,7 @@ 44E4ECBCE05E2A7A5F2EE2CD /* Products */ = { isa = PBXGroup; children = ( - 8EDDD96648AEBE55B27703CD /* GitSameBadge.app */, + 8EDDD96648AEBE55B27703CD /* GitSameSwiftApp.app */, AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */, ); name = Products; @@ -166,9 +166,9 @@ productReference = AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */; productType = "com.apple.product-type.app-extension"; }; - 9299C38CC4E9759F948732EE /* GitSameBadge */ = { + 9299C38CC4E9759F948732EE /* GitSameSwiftApp */ = { isa = PBXNativeTarget; - buildConfigurationList = ED1FA95BAF692E77F6FE377D /* Build configuration list for PBXNativeTarget "GitSameBadge" */; + buildConfigurationList = ED1FA95BAF692E77F6FE377D /* Build configuration list for PBXNativeTarget "GitSameSwiftApp" */; buildPhases = ( 275130C7732709B4EC961D97 /* Sources */, 774AE2229A24E660BCEBADE8 /* Embed Foundation Extensions */, @@ -178,11 +178,11 @@ dependencies = ( 8DB2E8AC2A85E61C740DF3B4 /* PBXTargetDependency */, ); - name = GitSameBadge; + name = GitSameSwiftApp; packageProductDependencies = ( ); - productName = GitSameBadge; - productReference = 8EDDD96648AEBE55B27703CD /* GitSameBadge.app */; + productName = GitSameSwiftApp; + productReference = 8EDDD96648AEBE55B27703CD /* GitSameSwiftApp.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -216,7 +216,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 9299C38CC4E9759F948732EE /* GitSameBadge */, + 9299C38CC4E9759F948732EE /* GitSameSwiftApp */, 0DD1148AB87797C1EFE47734 /* GitSameBadges */, ); }; @@ -368,10 +368,10 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = GitSameBadge/GitSameBadge.entitlements; + CODE_SIGN_ENTITLEMENTS = GitSameSwiftApp/GitSameSwiftApp.entitlements; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 57KL6Y7V32; - INFOPLIST_FILE = GitSameBadge/Info.plist; + INFOPLIST_FILE = GitSameSwiftApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -446,10 +446,10 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = GitSameBadge/GitSameBadge.entitlements; + CODE_SIGN_ENTITLEMENTS = GitSameSwiftApp/GitSameSwiftApp.entitlements; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 57KL6Y7V32; - INFOPLIST_FILE = GitSameBadge/Info.plist; + INFOPLIST_FILE = GitSameSwiftApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -482,7 +482,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - ED1FA95BAF692E77F6FE377D /* Build configuration list for PBXNativeTarget "GitSameBadge" */ = { + ED1FA95BAF692E77F6FE377D /* Build configuration list for PBXNativeTarget "GitSameSwiftApp" */ = { isa = XCConfigurationList; buildConfigurations = ( B87497438EDBE2BB4586AB78 /* Debug */, diff --git a/macos/GitSameBadge/App/GitSameBadgeApp.swift b/macos/GitSameSwiftApp/App/GitSameBadgeApp.swift similarity index 100% rename from macos/GitSameBadge/App/GitSameBadgeApp.swift rename to macos/GitSameSwiftApp/App/GitSameBadgeApp.swift diff --git a/macos/GitSameBadge/GitSameBadge.entitlements b/macos/GitSameSwiftApp/GitSameSwiftApp.entitlements similarity index 100% rename from macos/GitSameBadge/GitSameBadge.entitlements rename to macos/GitSameSwiftApp/GitSameSwiftApp.entitlements diff --git a/macos/GitSameBadge/Info.plist b/macos/GitSameSwiftApp/Info.plist similarity index 91% rename from macos/GitSameBadge/Info.plist rename to macos/GitSameSwiftApp/Info.plist index 4f87a5e..c2b4e3b 100644 --- a/macos/GitSameBadge/Info.plist +++ b/macos/GitSameSwiftApp/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - GitSameBadge + GitSameSwiftApp CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - GitSameBadge + GitSameSwiftApp CFBundlePackageType APPL CFBundleShortVersionString diff --git a/macos/GitSameBadge/Models/AppState.swift b/macos/GitSameSwiftApp/Models/AppState.swift similarity index 100% rename from macos/GitSameBadge/Models/AppState.swift rename to macos/GitSameSwiftApp/Models/AppState.swift diff --git a/macos/GitSameBadge/Views/ContentView.swift b/macos/GitSameSwiftApp/Views/ContentView.swift similarity index 100% rename from macos/GitSameBadge/Views/ContentView.swift rename to macos/GitSameSwiftApp/Views/ContentView.swift From 7fd2ae0975821a79494ffc252b7fc6a2a02e9e17 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 7 May 2026 22:05:51 +0200 Subject: [PATCH 36/89] Move macOS IPC to app-group container and rename badges extension Completes the macOS-side cleanup of the Tauri-host migration plan (Phase B.5 follow-ups + Phase C SwiftUI host removal): - Rename macos/GitSameBadge.xcodeproj/ -> macos/GitSameBadges.xcodeproj/ so the project name matches the only target left after this change. - Delete the SwiftUI host source under macos/GitSameSwiftApp/ now that the Tauri host scaffold is taking over (the Tauri crate lands in a later commit). The Xcode project no longer references the host target, so removing the source files is safe. - Rename the shared constants enum GitSameBadgeConstants -> GitSameBadgesConstants and update all call sites in BadgeManager, ContextMenuBuilder, FinderSync, SocketProtocol, StatusReader. Plural matches the user-facing extension display name (Git-Same-Badges) and the renamed Xcode target. - Replace residual "GitSameBadge" branding strings in the extension Swift code: NSMenu(title:), toolbarItemName, toolbarItemToolTip -> "git-Same"; OSLog subsystem -> "com.zaai.git-same.badges" so log filtering matches the new bundle id. Bundle id com.zaai.git-same.badges (extension) and com.zaai.git-same (host) from Phase B.5 are unchanged. The Tauri scaffold, Phase D bundler scripts, and the daemon plist binary-path placeholder land in separate follow-up commits. --- .../project.pbxproj | 172 +----------------- .../contents.xcworkspacedata | 0 .../xcschemes/GitSameBadges.xcscheme | 51 ++++++ macos/GitSameBadges/BadgeManager.swift | 14 +- macos/GitSameBadges/ContextMenuBuilder.swift | 4 +- macos/GitSameBadges/FinderSync.swift | 24 +-- .../GitSameSwiftApp/App/GitSameBadgeApp.swift | 18 -- .../GitSameSwiftApp.entitlements | 21 --- macos/GitSameSwiftApp/Info.plist | 26 --- macos/GitSameSwiftApp/Models/AppState.swift | 97 ---------- macos/GitSameSwiftApp/Views/ContentView.swift | 132 -------------- macos/Shared/Constants.swift | 2 +- macos/Shared/SocketProtocol.swift | 2 +- macos/Shared/StatusReader.swift | 4 +- 14 files changed, 78 insertions(+), 489 deletions(-) rename macos/{GitSameBadge.xcodeproj => GitSameBadges.xcodeproj}/project.pbxproj (63%) rename macos/{GitSameBadge.xcodeproj => GitSameBadges.xcodeproj}/project.xcworkspace/contents.xcworkspacedata (100%) create mode 100644 macos/GitSameBadges.xcodeproj/xcshareddata/xcschemes/GitSameBadges.xcscheme delete mode 100644 macos/GitSameSwiftApp/App/GitSameBadgeApp.swift delete mode 100644 macos/GitSameSwiftApp/GitSameSwiftApp.entitlements delete mode 100644 macos/GitSameSwiftApp/Info.plist delete mode 100644 macos/GitSameSwiftApp/Models/AppState.swift delete mode 100644 macos/GitSameSwiftApp/Views/ContentView.swift diff --git a/macos/GitSameBadge.xcodeproj/project.pbxproj b/macos/GitSameBadges.xcodeproj/project.pbxproj similarity index 63% rename from macos/GitSameBadge.xcodeproj/project.pbxproj rename to macos/GitSameBadges.xcodeproj/project.pbxproj index 6d83e27..8d7ed85 100644 --- a/macos/GitSameBadge.xcodeproj/project.pbxproj +++ b/macos/GitSameBadges.xcodeproj/project.pbxproj @@ -8,82 +8,31 @@ /* Begin PBXBuildFile section */ 0C33F590E865457705EBD3FC /* BadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */; }; - 257B6856845A3AF4D14530C1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66AB39502A80452129E1480B /* ContentView.swift */; }; - 3C0E8784F4D4172A82A47E2A /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545B0726C8517036CC45E5F5 /* Constants.swift */; }; - 3EBDCECE5F467D2A9EFB1556 /* GitSameBadges.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5367D36A8F6767ED63560577 /* SocketProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81934E8C1D14054D093A454 /* SocketProtocol.swift */; }; - 823E8A57D3D847A5045E28C5 /* StatusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8F66E45A3050726370F5B8 /* StatusModels.swift */; }; B3B75C712A824804DCF981B0 /* ContextMenuBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */; }; - B6105AA744F70B4B575E3571 /* SocketProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81934E8C1D14054D093A454 /* SocketProtocol.swift */; }; - C30B630B3AF0EFA7329FADA3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B6E312068B04A2AB272376 /* AppState.swift */; }; DAB8F1E9D9269A7732C09F70 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545B0726C8517036CC45E5F5 /* Constants.swift */; }; DC172C62A207996085942C13 /* FinderSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15307DCF3AB10B0512CD020A /* FinderSync.swift */; }; - E6366A4D11CE276E569491E4 /* StatusReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F95DF884CB07855C1D7C66 /* StatusReader.swift */; }; EB3305CF18666124EB44A7EE /* StatusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8F66E45A3050726370F5B8 /* StatusModels.swift */; }; - F89A4BB411E9B253046349E2 /* GitSameBadgeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DE9F6071FC977891FF3134 /* GitSameBadgeApp.swift */; }; FFC45929858BE49E81222E0E /* StatusReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F95DF884CB07855C1D7C66 /* StatusReader.swift */; }; /* End PBXBuildFile section */ -/* Begin PBXContainerItemProxy section */ - 90C0C4F8E56DE3CE22CC7031 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 76273B1C39F5CA108DFFF958 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 0DD1148AB87797C1EFE47734; - remoteInfo = GitSameBadges; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 774AE2229A24E660BCEBADE8 /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - 3EBDCECE5F467D2A9EFB1556 /* GitSameBadges.appex in Embed Foundation Extensions */, - ); - name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - /* Begin PBXFileReference section */ 15307DCF3AB10B0512CD020A /* FinderSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinderSync.swift; sourceTree = ""; }; 545B0726C8517036CC45E5F5 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 61AF581BDDD5DCB1387D64E4 /* GitSameBadges.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameBadges.entitlements; sourceTree = ""; }; - 66AB39502A80452129E1480B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 83B6E312068B04A2AB272376 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; - 8D40CF5193F71326936E5020 /* GitSameSwiftApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameSwiftApp.entitlements; sourceTree = ""; }; - 8EDDD96648AEBE55B27703CD /* GitSameSwiftApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GitSameSwiftApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuBuilder.swift; sourceTree = ""; }; 9D8F66E45A3050726370F5B8 /* StatusModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusModels.swift; sourceTree = ""; }; AB229AA7479834C325EB99A8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GitSameBadges.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeManager.swift; sourceTree = ""; }; C6F95DF884CB07855C1D7C66 /* StatusReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusReader.swift; sourceTree = ""; }; - E4F4D50A6C496D2398C988EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - E6DE9F6071FC977891FF3134 /* GitSameBadgeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitSameBadgeApp.swift; sourceTree = ""; }; E81934E8C1D14054D093A454 /* SocketProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketProtocol.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ - 10538FA5EC703BB6456B0A69 /* GitSameSwiftApp */ = { - isa = PBXGroup; - children = ( - 8D40CF5193F71326936E5020 /* GitSameSwiftApp.entitlements */, - E4F4D50A6C496D2398C988EA /* Info.plist */, - CB0CFDD7D56E2F16E9FC7F4D /* App */, - D692F12A3FB2A18EBF23C852 /* Models */, - DAA91174E582BD5E9000217A /* Views */, - ); - path = GitSameSwiftApp; - sourceTree = ""; - }; 43651E8F046591C71351AE27 = { isa = PBXGroup; children = ( - 10538FA5EC703BB6456B0A69 /* GitSameSwiftApp */, 75232AD31082397A565782DE /* GitSameBadges */, 6CFF0D192987E49E1BA2859F /* Shared */, 44E4ECBCE05E2A7A5F2EE2CD /* Products */, @@ -93,7 +42,6 @@ 44E4ECBCE05E2A7A5F2EE2CD /* Products */ = { isa = PBXGroup; children = ( - 8EDDD96648AEBE55B27703CD /* GitSameSwiftApp.app */, AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */, ); name = Products; @@ -122,30 +70,6 @@ path = GitSameBadges; sourceTree = ""; }; - CB0CFDD7D56E2F16E9FC7F4D /* App */ = { - isa = PBXGroup; - children = ( - E6DE9F6071FC977891FF3134 /* GitSameBadgeApp.swift */, - ); - path = App; - sourceTree = ""; - }; - D692F12A3FB2A18EBF23C852 /* Models */ = { - isa = PBXGroup; - children = ( - 83B6E312068B04A2AB272376 /* AppState.swift */, - ); - path = Models; - sourceTree = ""; - }; - DAA91174E582BD5E9000217A /* Views */ = { - isa = PBXGroup; - children = ( - 66AB39502A80452129E1480B /* ContentView.swift */, - ); - path = Views; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -166,25 +90,6 @@ productReference = AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */; productType = "com.apple.product-type.app-extension"; }; - 9299C38CC4E9759F948732EE /* GitSameSwiftApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = ED1FA95BAF692E77F6FE377D /* Build configuration list for PBXNativeTarget "GitSameSwiftApp" */; - buildPhases = ( - 275130C7732709B4EC961D97 /* Sources */, - 774AE2229A24E660BCEBADE8 /* Embed Foundation Extensions */, - ); - buildRules = ( - ); - dependencies = ( - 8DB2E8AC2A85E61C740DF3B4 /* PBXTargetDependency */, - ); - name = GitSameSwiftApp; - packageProductDependencies = ( - ); - productName = GitSameSwiftApp; - productReference = 8EDDD96648AEBE55B27703CD /* GitSameSwiftApp.app */; - productType = "com.apple.product-type.application"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -197,12 +102,9 @@ 0DD1148AB87797C1EFE47734 = { ProvisioningStyle = Automatic; }; - 9299C38CC4E9759F948732EE = { - ProvisioningStyle = Automatic; - }; }; }; - buildConfigurationList = C800113306606019226F625B /* Build configuration list for PBXProject "GitSameBadge" */; + buildConfigurationList = C800113306606019226F625B /* Build configuration list for PBXProject "GitSameBadges" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -216,27 +118,12 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 9299C38CC4E9759F948732EE /* GitSameSwiftApp */, 0DD1148AB87797C1EFE47734 /* GitSameBadges */, ); }; /* End PBXProject section */ /* Begin PBXSourcesBuildPhase section */ - 275130C7732709B4EC961D97 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - C30B630B3AF0EFA7329FADA3 /* AppState.swift in Sources */, - 3C0E8784F4D4172A82A47E2A /* Constants.swift in Sources */, - 257B6856845A3AF4D14530C1 /* ContentView.swift in Sources */, - F89A4BB411E9B253046349E2 /* GitSameBadgeApp.swift in Sources */, - B6105AA744F70B4B575E3571 /* SocketProtocol.swift in Sources */, - 823E8A57D3D847A5045E28C5 /* StatusModels.swift in Sources */, - E6366A4D11CE276E569491E4 /* StatusReader.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; D43CBF7D710B9FD3DCC5713E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -253,14 +140,6 @@ }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - 8DB2E8AC2A85E61C740DF3B4 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 0DD1148AB87797C1EFE47734 /* GitSameBadges */; - targetProxy = 90C0C4F8E56DE3CE22CC7031 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - /* Begin XCBuildConfiguration section */ 4D4BC397944FC7FCA785061B /* Debug */ = { isa = XCBuildConfiguration; @@ -364,25 +243,6 @@ }; name = Release; }; - B87497438EDBE2BB4586AB78 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = GitSameSwiftApp/GitSameSwiftApp.entitlements; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 57KL6Y7V32; - INFOPLIST_FILE = GitSameSwiftApp/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../PlugIns", - ); - PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same"; - SDKROOT = macosx; - SWIFT_OBJC_BRIDGING_HEADER = ""; - }; - name = Debug; - }; C103BD2520D00F5F82A14303 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -442,25 +302,6 @@ }; name = Release; }; - D38F6C9211903948B76E7158 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = GitSameSwiftApp/GitSameSwiftApp.entitlements; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 57KL6Y7V32; - INFOPLIST_FILE = GitSameSwiftApp/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../PlugIns", - ); - PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same"; - SDKROOT = macosx; - SWIFT_OBJC_BRIDGING_HEADER = ""; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -473,7 +314,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - C800113306606019226F625B /* Build configuration list for PBXProject "GitSameBadge" */ = { + C800113306606019226F625B /* Build configuration list for PBXProject "GitSameBadges" */ = { isa = XCConfigurationList; buildConfigurations = ( 4D4BC397944FC7FCA785061B /* Debug */, @@ -482,15 +323,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - ED1FA95BAF692E77F6FE377D /* Build configuration list for PBXNativeTarget "GitSameSwiftApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B87497438EDBE2BB4586AB78 /* Debug */, - D38F6C9211903948B76E7158 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; /* End XCConfigurationList section */ }; rootObject = 76273B1C39F5CA108DFFF958 /* Project object */; diff --git a/macos/GitSameBadge.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/macos/GitSameBadges.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from macos/GitSameBadge.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to macos/GitSameBadges.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/macos/GitSameBadges.xcodeproj/xcshareddata/xcschemes/GitSameBadges.xcscheme b/macos/GitSameBadges.xcodeproj/xcshareddata/xcschemes/GitSameBadges.xcscheme new file mode 100644 index 0000000..6fa5366 --- /dev/null +++ b/macos/GitSameBadges.xcodeproj/xcshareddata/xcschemes/GitSameBadges.xcscheme @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + diff --git a/macos/GitSameBadges/BadgeManager.swift b/macos/GitSameBadges/BadgeManager.swift index 8940fac..0662c12 100644 --- a/macos/GitSameBadges/BadgeManager.swift +++ b/macos/GitSameBadges/BadgeManager.swift @@ -13,37 +13,37 @@ enum BadgeManager { controller.setBadgeImage( labeledBadge(text: "R", color: .systemGreen), label: "Synced", - forBadgeIdentifier: GitSameBadgeConstants.BadgeID.green + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.green ) controller.setBadgeImage( labeledBadge(text: "R", color: .systemBlue), label: "Has Local Config", - forBadgeIdentifier: GitSameBadgeConstants.BadgeID.blue + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.blue ) controller.setBadgeImage( labeledBadge(text: "R", color: .systemOrange), label: "Partially Synced", - forBadgeIdentifier: GitSameBadgeConstants.BadgeID.orange + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.orange ) controller.setBadgeImage( labeledBadge(text: "R", color: .systemRed), label: "Uncommitted Changes", - forBadgeIdentifier: GitSameBadgeConstants.BadgeID.red + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.red ) controller.setBadgeImage( labeledBadge(text: "R", color: .systemGray), label: "Git Repository", - forBadgeIdentifier: GitSameBadgeConstants.BadgeID.gray + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.gray ) controller.setBadgeImage( labeledBadge(text: "O", color: .systemPurple), label: "Organization", - forBadgeIdentifier: GitSameBadgeConstants.BadgeID.org + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.org ) controller.setBadgeImage( labeledBadge(text: "U", color: .systemTeal), label: "User", - forBadgeIdentifier: GitSameBadgeConstants.BadgeID.user + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.user ) } diff --git a/macos/GitSameBadges/ContextMenuBuilder.swift b/macos/GitSameBadges/ContextMenuBuilder.swift index 75f6288..ca1a7b4 100644 --- a/macos/GitSameBadges/ContextMenuBuilder.swift +++ b/macos/GitSameBadges/ContextMenuBuilder.swift @@ -20,7 +20,7 @@ enum ContextMenuBuilder { if repo.badge == .gray { socketClient.send("REFRESH \(repo.path)") { _ in } } - let menu = NSMenu(title: "GitSameBadge") + let menu = NSMenu(title: "git-Same") menu.addItem(parentItem(badge: repo.badge, submenu: repoRoot(repo: repo, workspaceInfo: workspaceInfo, @@ -33,7 +33,7 @@ enum ContextMenuBuilder { repos: [FinderRepoStatus], workspaceInfo: FinderWorkspaceInfo?, timestamp: String?) -> NSMenu { - let menu = NSMenu(title: "GitSameBadge") + let menu = NSMenu(title: "git-Same") menu.addItem(parentItem(badge: nil, submenu: orgRoot(org: org, repos: repos, workspaceInfo: workspaceInfo, diff --git a/macos/GitSameBadges/FinderSync.swift b/macos/GitSameBadges/FinderSync.swift index d75568e..2303641 100644 --- a/macos/GitSameBadges/FinderSync.swift +++ b/macos/GitSameBadges/FinderSync.swift @@ -5,7 +5,7 @@ import Cocoa import FinderSync import os -private let gsbLog = OSLog(subsystem: "com.zaai.git-same.GitSameBadge.FinderSync", category: "ext") +private let gsbLog = OSLog(subsystem: "com.zaai.git-same.badges", category: "ext") class FinderSync: FIFinderSync { @@ -80,8 +80,8 @@ class FinderSync: FIFinderSync { if let orgFolder = orgFolderLookup(path: path, resolved: resolved) { let finalID = orgFolder.ownerType == .user - ? GitSameBadgeConstants.BadgeID.user - : GitSameBadgeConstants.BadgeID.org + ? GitSameBadgesConstants.BadgeID.user + : GitSameBadgesConstants.BadgeID.org controller.setBadgeIdentifier(finalID, for: url) return } @@ -119,11 +119,11 @@ class FinderSync: FIFinderSync { private func badgeID(for badge: Badge) -> String { switch badge { - case .green: return GitSameBadgeConstants.BadgeID.green - case .blue: return GitSameBadgeConstants.BadgeID.blue - case .orange: return GitSameBadgeConstants.BadgeID.orange - case .red: return GitSameBadgeConstants.BadgeID.red - case .gray: return GitSameBadgeConstants.BadgeID.gray + case .green: return GitSameBadgesConstants.BadgeID.green + case .blue: return GitSameBadgesConstants.BadgeID.blue + case .orange: return GitSameBadgesConstants.BadgeID.orange + case .red: return GitSameBadgesConstants.BadgeID.red + case .gray: return GitSameBadgesConstants.BadgeID.gray } } @@ -139,8 +139,8 @@ class FinderSync: FIFinderSync { for orgFolder in status.orgFolders ?? [] { let url = URL(fileURLWithPath: orgFolder.path) let finalID = orgFolder.ownerType == .user - ? GitSameBadgeConstants.BadgeID.user - : GitSameBadgeConstants.BadgeID.org + ? GitSameBadgesConstants.BadgeID.user + : GitSameBadgesConstants.BadgeID.org controller.setBadgeIdentifier(finalID, for: url) } @@ -164,11 +164,11 @@ class FinderSync: FIFinderSync { // MARK: - Toolbar override var toolbarItemName: String { - return "GitSameBadge" + return "git-Same" } override var toolbarItemToolTip: String { - return "GitSameBadge repository status" + return "git-Same repository status" } override var toolbarItemImage: NSImage { diff --git a/macos/GitSameSwiftApp/App/GitSameBadgeApp.swift b/macos/GitSameSwiftApp/App/GitSameBadgeApp.swift deleted file mode 100644 index 72ff5da..0000000 --- a/macos/GitSameSwiftApp/App/GitSameBadgeApp.swift +++ /dev/null @@ -1,18 +0,0 @@ -// GitSameBadgeApp.swift -// Main entry point for the GitSameBadge host app. -// This is the seed for the future full macOS app. - -import SwiftUI - -@main -struct GitSameBadgeApp: App { - @StateObject private var appState = AppState() - - var body: some Scene { - WindowGroup { - ContentView() - .environmentObject(appState) - } - .windowResizability(.contentSize) - } -} diff --git a/macos/GitSameSwiftApp/GitSameSwiftApp.entitlements b/macos/GitSameSwiftApp/GitSameSwiftApp.entitlements deleted file mode 100644 index 1117d8b..0000000 --- a/macos/GitSameSwiftApp/GitSameSwiftApp.entitlements +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - com.apple.security.app-sandbox - - com.apple.security.application-groups - - group.57KL6Y7V32.com.zaai.git-same - - - diff --git a/macos/GitSameSwiftApp/Info.plist b/macos/GitSameSwiftApp/Info.plist deleted file mode 100644 index c2b4e3b..0000000 --- a/macos/GitSameSwiftApp/Info.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - GitSameSwiftApp - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - com.zaai.git-same - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - GitSameSwiftApp - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - 13.0 - - diff --git a/macos/GitSameSwiftApp/Models/AppState.swift b/macos/GitSameSwiftApp/Models/AppState.swift deleted file mode 100644 index 0c86a9a..0000000 --- a/macos/GitSameSwiftApp/Models/AppState.swift +++ /dev/null @@ -1,97 +0,0 @@ -// AppState.swift -// Central observable state for the host app. - -import Foundation -import SwiftUI - -class AppState: ObservableObject { - @Published var isDaemonRunning: Bool = false - @Published var daemonPID: UInt32? - @Published var lastScan: String? - @Published var repoCount: Int = 0 - @Published var workspaces: [FinderWorkspaceInfo] = [] - - private var refreshTimer: Timer? - private let statusReader = StatusReader.shared - - init() { - refresh() - // Periodically refresh daemon status - refreshTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in - self?.refresh() - } - } - - /// Refresh state from the status file. - func refresh() { - statusReader.reload() - guard let status = statusReader.currentStatus else { - isDaemonRunning = false - daemonPID = nil - lastScan = nil - repoCount = 0 - workspaces = [] - return - } - - daemonPID = status.daemonPid - lastScan = status.timestamp - repoCount = status.repos.count - workspaces = status.workspaces - - // Check if daemon PID is alive - isDaemonRunning = isProcessAlive(pid: status.daemonPid) - } - - /// Start the daemon. - func startDaemon() { - let binaryPath = GitSameBadgeConstants.daemonBinaryPath - let process = Process() - process.executableURL = URL(fileURLWithPath: binaryPath) - process.arguments = ["daemon", "--foreground"] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - - do { - try process.run() - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - self?.refresh() - } - } catch { - // Failed to start daemon - } - } - - /// Stop the daemon. - func stopDaemon() { - guard let pid = daemonPID else { return } - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/kill") - process.arguments = ["-TERM", "\(pid)"] - try? process.run() - process.waitUntilExit() - - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.refresh() - } - } - - private func isProcessAlive(pid: UInt32) -> Bool { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/kill") - process.arguments = ["-0", "\(pid)"] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - do { - try process.run() - process.waitUntilExit() - return process.terminationStatus == 0 - } catch { - return false - } - } - - deinit { - refreshTimer?.invalidate() - } -} diff --git a/macos/GitSameSwiftApp/Views/ContentView.swift b/macos/GitSameSwiftApp/Views/ContentView.swift deleted file mode 100644 index a7a70f5..0000000 --- a/macos/GitSameSwiftApp/Views/ContentView.swift +++ /dev/null @@ -1,132 +0,0 @@ -// ContentView.swift -// Main window content for the GitSameBadge host app. - -import SwiftUI - -struct ContentView: View { - @EnvironmentObject var appState: AppState - - var body: some View { - NavigationSplitView { - // Sidebar - List { - Section("Status") { - NavigationLink(destination: DaemonStatusView()) { - Label("Daemon", systemImage: "server.rack") - } - } - - Section("Workspaces") { - ForEach(appState.workspaces, id: \.name) { workspace in - NavigationLink(destination: WorkspaceDetailView(workspace: workspace)) { - Label(workspace.name, systemImage: "folder") - } - } - } - - Section("Settings") { - NavigationLink(destination: SettingsView()) { - Label("Preferences", systemImage: "gear") - } - } - } - .listStyle(.sidebar) - .frame(minWidth: 180) - } detail: { - DaemonStatusView() - } - .frame(minWidth: 600, minHeight: 400) - } -} - -struct DaemonStatusView: View { - @EnvironmentObject var appState: AppState - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - // Daemon status header - HStack { - Circle() - .fill(appState.isDaemonRunning ? Color.green : Color.red) - .frame(width: 12, height: 12) - Text(appState.isDaemonRunning ? "Daemon Running" : "Daemon Stopped") - .font(.title2) - .fontWeight(.semibold) - } - - if let pid = appState.daemonPID, appState.isDaemonRunning { - LabeledContent("PID", value: "\(pid)") - } - if let lastScan = appState.lastScan { - LabeledContent("Last Scan", value: lastScan) - } - LabeledContent("Repos Monitored", value: "\(appState.repoCount)") - - Divider() - - // Actions - HStack { - if appState.isDaemonRunning { - Button("Stop Daemon") { - appState.stopDaemon() - } - Button("Refresh") { - appState.refresh() - } - } else { - Button("Start Daemon") { - appState.startDaemon() - } - } - } - - Spacer() - - // Extension status hint - Text("To enable the Finder extension, go to:") - .font(.caption) - .foregroundColor(.secondary) - Text("System Settings > Privacy & Security > Extensions > Finder") - .font(.caption) - .foregroundColor(.secondary) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -struct WorkspaceDetailView: View { - let workspace: FinderWorkspaceInfo - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(workspace.name) - .font(.title2) - .fontWeight(.semibold) - - LabeledContent("Root", value: workspace.root) - LabeledContent("Organizations", value: workspace.orgs.joined(separator: ", ")) - - Spacer() - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -struct SettingsView: View { - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Preferences") - .font(.title2) - .fontWeight(.semibold) - - Text("Settings will be available in a future update.") - .foregroundColor(.secondary) - - Spacer() - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } -} diff --git a/macos/Shared/Constants.swift b/macos/Shared/Constants.swift index 11d6a5e..14404ee 100644 --- a/macos/Shared/Constants.swift +++ b/macos/Shared/Constants.swift @@ -13,7 +13,7 @@ import Foundation -enum GitSameBadgeConstants { +enum GitSameBadgesConstants { /// App group shared between the host app, the Badges extension, and the /// daemon. Apple requires the team-id prefix. /// Mirrors the Rust `git_same_core::ipc::APP_GROUP_ID`. diff --git a/macos/Shared/SocketProtocol.swift b/macos/Shared/SocketProtocol.swift index 157f06d..e7ca0d1 100644 --- a/macos/Shared/SocketProtocol.swift +++ b/macos/Shared/SocketProtocol.swift @@ -8,7 +8,7 @@ import Network class SocketClient { private let socketPath: String - init(socketPath: String = GitSameBadgeConstants.socketPath) { + init(socketPath: String = GitSameBadgesConstants.socketPath) { self.socketPath = socketPath } diff --git a/macos/Shared/StatusReader.swift b/macos/Shared/StatusReader.swift index 4255557..f9e1f3e 100644 --- a/macos/Shared/StatusReader.swift +++ b/macos/Shared/StatusReader.swift @@ -28,7 +28,7 @@ class StatusReader { /// Start watching the status file for changes. func startWatching() { - let path = GitSameBadgeConstants.statusFilePath + let path = GitSameBadgesConstants.statusFilePath // Open file descriptor for monitoring fileDescriptor = open(path, O_EVTONLY) @@ -82,7 +82,7 @@ class StatusReader { /// Reload and parse the status file. func reload() { - let path = GitSameBadgeConstants.statusFilePath + let path = GitSameBadgesConstants.statusFilePath guard let data = FileManager.default.contents(atPath: path) else { return } do { From e1a9eaec0fd3e85a8016ad8f0d52eb1bb77f04e5 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 01:16:49 +0200 Subject: [PATCH 37/89] Add Tauri macOS app pipeline to ship GUI and Finder badges --- .github/workflows/S1-Test-CI.yml | 33 + .github/workflows/S3-Publish-Homebrew.yml | 10 +- .github/workflows/S5-Build-MacOS-App.yml | 129 + .gitignore | 5 + Cargo.lock | 3118 ++++++++++++++++- Cargo.toml | 7 +- crates/git-same-app/Cargo.toml | 27 + crates/git-same-app/build.rs | 3 + crates/git-same-app/entitlements.plist | 10 + crates/git-same-app/icons/1024x1024.png | Bin 0 -> 37349 bytes crates/git-same-app/icons/128x128.png | Bin 0 -> 9250 bytes crates/git-same-app/icons/128x128@2x.png | Bin 0 -> 18894 bytes crates/git-same-app/icons/32x32.png | Bin 0 -> 2357 bytes crates/git-same-app/icons/icon.icns | Bin 0 -> 227332 bytes crates/git-same-app/icons/icon.ico | Bin 0 -> 370070 bytes crates/git-same-app/src/commands.rs | 167 + crates/git-same-app/src/main.rs | 19 + crates/git-same-app/src/status_stream.rs | 44 + crates/git-same-app/tauri.conf.json | 44 + crates/git-same-app/ui/index.html | 12 + crates/git-same-app/ui/package.json | 27 + crates/git-same-app/ui/pnpm-lock.yaml | 1092 ++++++ crates/git-same-app/ui/pnpm-workspace.yaml | 2 + crates/git-same-app/ui/src/App.svelte | 564 +++ crates/git-same-app/ui/src/lib/tauri.ts | 19 + crates/git-same-app/ui/src/lib/types.ts | 54 + crates/git-same-app/ui/src/main.ts | 9 + crates/git-same-app/ui/src/styles/tokens.css | 57 + crates/git-same-app/ui/tsconfig.json | 18 + crates/git-same-app/ui/tsconfig.node.json | 10 + crates/git-same-app/ui/vite.config.ts | 12 + .../src/commands/sync_cmd_tests.rs | 7 +- crates/git-same-cli/src/lib.rs | 4 + .../git-same-cli/src/setup/handler_tests.rs | 12 + crates/git-same-core/src/config/parser.rs | 2 +- .../git-same-core/src/types/finder_status.rs | 2 +- macos/com.zaai.git-same.daemon.plist | 2 +- toolkit/homebrew/cask.rb.tmpl | 47 +- toolkit/homebrew/render-cask.sh | 4 +- toolkit/packaging/macos/build-app-bundle.sh | 161 + toolkit/packaging/macos/sign-app-bundle.sh | 168 + 41 files changed, 5697 insertions(+), 204 deletions(-) create mode 100644 .github/workflows/S5-Build-MacOS-App.yml create mode 100644 crates/git-same-app/Cargo.toml create mode 100644 crates/git-same-app/build.rs create mode 100644 crates/git-same-app/entitlements.plist create mode 100644 crates/git-same-app/icons/1024x1024.png create mode 100644 crates/git-same-app/icons/128x128.png create mode 100644 crates/git-same-app/icons/128x128@2x.png create mode 100644 crates/git-same-app/icons/32x32.png create mode 100644 crates/git-same-app/icons/icon.icns create mode 100644 crates/git-same-app/icons/icon.ico create mode 100644 crates/git-same-app/src/commands.rs create mode 100644 crates/git-same-app/src/main.rs create mode 100644 crates/git-same-app/src/status_stream.rs create mode 100644 crates/git-same-app/tauri.conf.json create mode 100644 crates/git-same-app/ui/index.html create mode 100644 crates/git-same-app/ui/package.json create mode 100644 crates/git-same-app/ui/pnpm-lock.yaml create mode 100644 crates/git-same-app/ui/pnpm-workspace.yaml create mode 100644 crates/git-same-app/ui/src/App.svelte create mode 100644 crates/git-same-app/ui/src/lib/tauri.ts create mode 100644 crates/git-same-app/ui/src/lib/types.ts create mode 100644 crates/git-same-app/ui/src/main.ts create mode 100644 crates/git-same-app/ui/src/styles/tokens.css create mode 100644 crates/git-same-app/ui/tsconfig.json create mode 100644 crates/git-same-app/ui/tsconfig.node.json create mode 100644 crates/git-same-app/ui/vite.config.ts create mode 100755 toolkit/packaging/macos/build-app-bundle.sh create mode 100755 toolkit/packaging/macos/sign-app-bundle.sh diff --git a/.github/workflows/S1-Test-CI.yml b/.github/workflows/S1-Test-CI.yml index e61d193..05b8d84 100644 --- a/.github/workflows/S1-Test-CI.yml +++ b/.github/workflows/S1-Test-CI.yml @@ -83,6 +83,39 @@ jobs: - name: Build release run: cargo +stable build --release -p git-same --target ${{ matrix.target }} + tauri-debug-build: + name: Tauri App Debug Build + needs: [test] + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Enable pnpm + run: corepack enable pnpm + + - name: Install frontend dependencies + run: pnpm --dir crates/git-same-app/ui install --frozen-lockfile + + - name: Build frontend + run: pnpm --dir crates/git-same-app/ui build + + - name: Build Tauri app binary + run: | + cd crates/git-same-app + ui/node_modules/.bin/tauri build --debug --no-bundle + coverage: name: Code Coverage runs-on: macos-latest diff --git a/.github/workflows/S3-Publish-Homebrew.yml b/.github/workflows/S3-Publish-Homebrew.yml index ff192c7..72302c2 100644 --- a/.github/workflows/S3-Publish-Homebrew.yml +++ b/.github/workflows/S3-Publish-Homebrew.yml @@ -54,6 +54,8 @@ jobs: --pattern "git-same-${VERSION}-aarch64-unknown-linux-gnu.tar.gz" \ --pattern "git-same-${VERSION}-x86_64-apple-darwin.tar.gz" \ --pattern "git-same-${VERSION}-aarch64-apple-darwin.tar.gz" \ + --pattern "git-same-${VERSION}-x86_64.dmg" \ + --pattern "git-same-${VERSION}-aarch64.dmg" \ --dir assets - name: Compute SHA256 hashes @@ -64,10 +66,14 @@ jobs: LINUX_ARM="assets/git-same-${VERSION}-aarch64-unknown-linux-gnu.tar.gz" MAC_X86="assets/git-same-${VERSION}-x86_64-apple-darwin.tar.gz" MAC_ARM="assets/git-same-${VERSION}-aarch64-apple-darwin.tar.gz" + CASK_MAC_X86="assets/git-same-${VERSION}-x86_64.dmg" + CASK_MAC_ARM="assets/git-same-${VERSION}-aarch64.dmg" echo "linux_x86_64=$(shasum -a 256 "$LINUX_X86" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "linux_aarch64=$(shasum -a 256 "$LINUX_ARM" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "macos_x86_64=$(shasum -a 256 "$MAC_X86" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "macos_aarch64=$(shasum -a 256 "$MAC_ARM" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "cask_macos_x86_64=$(shasum -a 256 "$CASK_MAC_X86" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "cask_macos_aarch64=$(shasum -a 256 "$CASK_MAC_ARM" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Render formula-cli run: | @@ -86,8 +92,8 @@ jobs: run: | bash toolkit/homebrew/render-cask.sh \ "${{ steps.version.outputs.version }}" \ - --sha-arm "${{ steps.sha.outputs.macos_aarch64 }}" \ - --sha-intel "${{ steps.sha.outputs.macos_x86_64 }}" \ + --sha-arm "${{ steps.sha.outputs.cask_macos_aarch64 }}" \ + --sha-intel "${{ steps.sha.outputs.cask_macos_x86_64 }}" \ --out cask.rb - name: Verify rendered cask + formula against a temp tap diff --git a/.github/workflows/S5-Build-MacOS-App.yml b/.github/workflows/S5-Build-MacOS-App.yml new file mode 100644 index 0000000..91ec60d --- /dev/null +++ b/.github/workflows/S5-Build-MacOS-App.yml @@ -0,0 +1,129 @@ +name: S5 - Build macOS App + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (e.g., 3.1.0)" + required: true + type: string + include_finder_extension: + description: "Embed the FinderSync extension" + required: true + type: boolean + default: false + push: + tags: + - "*.*.*" + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +permissions: + contents: write + +jobs: + build: + name: Build App (${{ matrix.arch }}) + runs-on: macos-14 + strategy: + fail-fast: false + matrix: + include: + - arch: aarch64 + target: aarch64-apple-darwin + - arch: x86_64 + target: x86_64-apple-darwin + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Resolve version + id: version + shell: bash + run: | + VERSION="${{ inputs.version }}" + if [ -z "$VERSION" ]; then + VERSION="${GITHUB_REF#refs/tags/}" + fi + if ! [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Version must be strict semver only. Got: $VERSION" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Install Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Enable pnpm + run: corepack enable pnpm + + - name: Build signed app DMG + env: + VERSION: ${{ steps.version.outputs.version }} + ARCH: ${{ matrix.arch }} + WORKSPACE_ROOT: ${{ github.workspace }} + OUTPUT_DIR: ${{ github.workspace }}/dist/macos + INCLUDE_FINDER_EXTENSION: ${{ inputs.include_finder_extension && '1' || '0' }} + APPLE_DEVELOPER_CERTIFICATE_P12: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12 }} + APPLE_DEVELOPER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ vars.APPLE_ID }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + run: bash toolkit/packaging/macos/build-app-bundle.sh + + - name: Upload app artifacts + uses: actions/upload-artifact@v7 + with: + name: git-same-${{ steps.version.outputs.version }}-${{ matrix.arch }}.dmg + path: | + dist/macos/git-same-${{ steps.version.outputs.version }}-${{ matrix.arch }}.dmg + dist/macos/git-same-${{ steps.version.outputs.version }}-${{ matrix.arch }}.dmg.sha256 + + release: + name: Attach App DMGs to GitHub Release + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Resolve version + id: version + shell: bash + run: | + VERSION="${{ inputs.version }}" + if [ -z "$VERSION" ]; then + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + + - name: Collect release assets + run: | + mkdir -p release-assets + find artifacts -type f -exec cp {} release-assets/ \; + + - name: Create/update release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.version.outputs.version }} + files: release-assets/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 6dced14..eaa64b9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ **/*.rs.bk *.pdb +# Frontend +node_modules/ +dist/ +crates/git-same-app/gen/ + # IDE .vscode/ .idea/ diff --git a/Cargo.lock b/Cargo.lock index 9b3afa3..c378ce1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -103,6 +124,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "atomic" version = "0.6.1" @@ -146,6 +190,12 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -158,7 +208,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -167,6 +226,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -178,6 +243,9 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -188,6 +256,36 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -200,11 +298,87 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] [[package]] name = "castaway" @@ -227,6 +401,33 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -250,7 +451,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -290,7 +491,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -381,6 +582,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -407,6 +618,30 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -416,6 +651,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.29.0" @@ -426,7 +685,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", - "mio", + "mio 1.2.0", "parking_lot", "rustix", "signal-hook", @@ -460,9 +719,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", - "phf", + "phf 0.11.3", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "darling" version = "0.23.0" @@ -497,6 +795,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + [[package]] name = "deltae" version = "0.3.2" @@ -510,6 +819,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -574,6 +884,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -586,73 +908,187 @@ dependencies = [ ] [[package]] -name = "document-features" -version = "0.2.12" +name = "dlopen2" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ - "litrs", + "dlopen2_derive", + "libc", + "once_cell", + "winapi", ] [[package]] -name = "dunce" -version = "1.0.5" +name = "dlopen2_derive" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "either" -version = "1.15.0" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] [[package]] -name = "encode_unicode" -version = "1.0.0" +name = "dom_query" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set 0.8.0", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "dpi" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" dependencies = [ - "cfg-if", + "serde", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "dtoa" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] -name = "errno" -version = "0.3.14" +name = "dtoa-short" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" dependencies = [ - "libc", - "windows-sys 0.61.2", + "dtoa", ] [[package]] -name = "euclid" -version = "0.22.14" +name = "dtor" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" dependencies = [ - "num-traits", + "dtor-proc-macro", ] [[package]] -name = "fancy-regex" +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] @@ -662,6 +1098,25 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -673,6 +1128,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -691,6 +1157,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -709,6 +1185,33 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -724,6 +1227,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -812,6 +1324,105 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -862,6 +1473,38 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + [[package]] name = "git-same" version = "3.1.0" @@ -882,11 +1525,27 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml", + "toml 1.1.2+spec-1.1.0", "tracing", "tracing-subscriber", ] +[[package]] +name = "git-same-app" +version = "3.1.0" +dependencies = [ + "anyhow", + "chrono", + "git-same-core", + "notify", + "serde", + "serde_json", + "shellexpand", + "tauri", + "tauri-build", + "tokio", +] + [[package]] name = "git-same-core" version = "3.1.0" @@ -907,67 +1566,205 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-test", - "toml", + "toml 1.1.2+spec-1.1.0", "tracing", "tracing-subscriber", ] [[package]] -name = "h2" -version = "0.4.14" +name = "glib" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "atomic-waker", - "bytes", - "fnv", + "bitflags 2.11.1", + "futures-channel", "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "glib-macros" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ - "foldhash 0.1.5", + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "glib-sys" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", + "libc", + "system-deps", ] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] -name = "heck" -version = "0.5.0" +name = "gobject-sys" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] [[package]] -name = "hex" -version = "0.4.3" +name = "gtk" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] [[package]] name = "http" @@ -1057,7 +1854,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1088,7 +1885,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1100,6 +1897,16 @@ dependencies = [ "cc", ] +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1215,6 +2022,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1249,6 +2067,35 @@ dependencies = [ "rustversion", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instability" version = "0.3.12" @@ -1289,6 +2136,45 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -1298,12 +2184,12 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1319,6 +2205,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -1360,6 +2255,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -1371,6 +2288,37 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lab" version = "0.11.0" @@ -1389,19 +2337,65 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libredox" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ + "bitflags 2.11.1", "libc", + "plain", + "redox_syscall 0.7.5", ] [[package]] @@ -1471,6 +2465,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1513,6 +2518,28 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.0" @@ -1551,21 +2578,72 @@ dependencies = [ ] [[package]] -name = "nix" -version = "0.29.0" +name = "muda" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", ] [[package]] -name = "nom" -version = "7.1.3" +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ @@ -1573,6 +2651,25 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1608,6 +2705,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1617,6 +2736,201 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1650,6 +2964,31 @@ dependencies = [ "num-traits", ] +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1668,9 +3007,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1728,8 +3067,19 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -1738,8 +3088,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", ] [[package]] @@ -1748,18 +3108,41 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.117", @@ -1774,12 +3157,72 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1810,6 +3253,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1821,25 +3270,87 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "proc-macro-crate" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" dependencies = [ - "unicode-ident", + "once_cell", + "toml_edit 0.19.15", ] [[package]] -name = "quinn" -version = "0.11.9" +name = "proc-macro-crate" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", "rustc-hash", "rustls", "socket2", @@ -2035,6 +3546,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2044,6 +3561,15 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -2055,6 +3581,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -2090,10 +3636,11 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -2115,12 +3662,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -2216,7 +3765,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -2277,6 +3826,57 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2306,11 +3906,34 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -2322,6 +3945,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2342,6 +3977,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -2355,6 +4001,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2376,6 +4042,68 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2428,7 +4156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio", + "mio 1.2.0", "signal-hook", ] @@ -2442,6 +4170,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -2492,6 +4226,54 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2504,6 +4286,30 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2520,84 +4326,378 @@ dependencies = [ ] [[package]] -name = "strum_macros" -version = "0.27.2" +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ - "heck", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] -name = "subtle" +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] [[package]] -name = "syn" -version = "1.0.109" +name = "tauri-codegen" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528" dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", "proc-macro2", "quote", - "unicode-ident", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", ] [[package]] -name = "syn" -version = "2.0.117" +name = "tauri-macros" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502" dependencies = [ + "heck 0.5.0", "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", ] [[package]] -name = "sync_wrapper" -version = "1.0.2" +name = "tauri-runtime" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc" dependencies = [ - "futures-core", + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", ] [[package]] -name = "synstructure" -version = "0.13.2" +name = "tauri-runtime-wry" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", ] [[package]] -name = "system-configuration" -version = "0.7.0" +name = "tauri-utils" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.9.4", - "system-configuration-sys", + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf 0.13.1", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", ] [[package]] -name = "system-configuration-sys" -version = "0.6.0" +name = "tauri-winres" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ - "core-foundation-sys", - "libc", + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -2613,6 +4713,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -2621,8 +4731,8 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", "nom", - "phf", - "phf_codegen", + "phf 0.11.3", + "phf_codegen 0.11.3", ] [[package]] @@ -2641,7 +4751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.11.1", "fancy-regex", "filedescriptor", @@ -2658,7 +4768,7 @@ dependencies = [ "ordered-float", "pest", "pest_derive", - "phf", + "phf 0.11.3", "sha2", "signal-hook", "siphasher", @@ -2732,12 +4842,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2746,6 +4858,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2779,7 +4901,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -2844,19 +4966,64 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2868,13 +5035,49 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.2", ] [[package]] @@ -2989,12 +5192,40 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.0" @@ -3007,6 +5238,47 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3064,8 +5336,27 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3087,6 +5378,7 @@ dependencies = [ "atomic", "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -3096,12 +5388,38 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -3223,12 +5541,25 @@ dependencies = [ name = "wasm-metadata" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -3239,7 +5570,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -3263,6 +5594,62 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" @@ -3272,6 +5659,42 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3375,6 +5798,56 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3383,9 +5856,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -3410,21 +5894,46 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3433,7 +5942,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3442,7 +5960,25 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", ] [[package]] @@ -3454,6 +5990,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -3469,7 +6014,37 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -3494,7 +6069,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3505,6 +6080,36 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3517,6 +6122,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3529,6 +6146,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3553,6 +6182,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3565,6 +6206,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3577,6 +6230,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3589,6 +6254,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3601,11 +6278,39 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] [[package]] name = "wit-bindgen" @@ -3629,7 +6334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -3640,8 +6345,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", - "indexmap", + "heck 0.5.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -3672,7 +6377,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3691,7 +6396,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -3707,6 +6412,71 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index f1f9466..7b1e17c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,9 @@ [workspace] -members = ["crates/git-same-core", "crates/git-same-cli"] +members = [ + "crates/git-same-core", + "crates/git-same-cli", + "crates/git-same-app", +] resolver = "2" [workspace.package] @@ -34,6 +38,7 @@ clap_mangen = "0.3" tokio-test = "0.4" mockito = "1" tempfile = "3" +notify = "6" [profile.release] strip = true diff --git a/crates/git-same-app/Cargo.toml b/crates/git-same-app/Cargo.toml new file mode 100644 index 0000000..23987a1 --- /dev/null +++ b/crates/git-same-app/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "git-same-app" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Desktop app for git-same." +publish = false + +[[bin]] +name = "git-same-app" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +git-same-core = { path = "../git-same-core" } +anyhow = { workspace = true } +chrono = { workspace = true } +notify = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +shellexpand = { workspace = true } +tauri = { version = "2", features = [] } +tokio = { workspace = true } diff --git a/crates/git-same-app/build.rs b/crates/git-same-app/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/crates/git-same-app/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/crates/git-same-app/entitlements.plist b/crates/git-same-app/entitlements.plist new file mode 100644 index 0000000..e26b832 --- /dev/null +++ b/crates/git-same-app/entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.57KL6Y7V32.com.zaai.git-same + + + diff --git a/crates/git-same-app/icons/1024x1024.png b/crates/git-same-app/icons/1024x1024.png new file mode 100644 index 0000000000000000000000000000000000000000..c42802ac532ebe2f92ff3be9d78b869a6855fdf6 GIT binary patch literal 37349 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�b3rU3=pI)JF~F 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 HPhI&RSprBM literal 0 HcmV?d00001 diff --git a/crates/git-same-app/icons/128x128.png b/crates/git-same-app/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..21d9670fbf7dd89e4b3c5b530ac0e611f454930e GIT binary patch literal 9250 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 X-r1fzrriLZcEsT6>gTe~DWM4f*wO_O literal 0 HcmV?d00001 diff --git a/crates/git-same-app/icons/32x32.png b/crates/git-same-app/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..6ee8347af2995f02c3c7b93932dc91cb10b7f210 GIT binary patch literal 2357 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%0D`n>)op|9? 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%0A=W5m#b7vW^nO@{+^=Qm)?>t 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 b9KSvvyqI0p+(G92jsN$lcnwnraliflv6l{E literal 0 HcmV?d00001 diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs new file mode 100644 index 0000000..ebba7c0 --- /dev/null +++ b/crates/git-same-app/src/commands.rs @@ -0,0 +1,167 @@ +use git_same_core::config::workspace::tilde_collapse_path; +use git_same_core::config::{Config, WorkspaceConfig, WorkspaceManager}; +use git_same_core::errors::AppError; +use git_same_core::ipc::{IpcConfig, StatusFileWriter}; +use git_same_core::operations::clone::NoProgress as NoCloneProgress; +use git_same_core::operations::sync::NoSyncProgress; +use git_same_core::provider::NoProgress as NoDiscoveryProgress; +use git_same_core::types::FinderStatus; +use git_same_core::workflows::sync_workspace::{ + execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest, +}; +use serde::Serialize; +use std::fs; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +const DAEMON_STALE_AFTER_SECS: u64 = 90; + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceSummary { + pub id: String, + pub root: String, + pub provider: String, + pub org_count: usize, + pub last_sync: Option, + pub default: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StatusSnapshot { + pub status_path: String, + pub updated_at: Option, + pub stale: bool, + pub status: Option, +} + +#[tauri::command] +pub async fn list_workspaces() -> Result, String> { + let config = Config::load().map_err(error_string)?; + let default_workspace = config.default_workspace.clone(); + let workspaces = WorkspaceManager::list().map_err(error_string)?; + + Ok(workspaces + .iter() + .map(|workspace| workspace_summary(workspace, default_workspace.as_deref())) + .collect()) +} + +#[tauri::command] +pub async fn read_status() -> Result { + read_status_snapshot().map_err(error_string) +} + +#[tauri::command] +pub async fn start_sync(workspace_id: String) -> Result<(), String> { + let config = Config::load().map_err(error_string)?; + let mut workspace = + WorkspaceManager::resolve(Some(&workspace_id), &config).map_err(error_string)?; + let progress = NoDiscoveryProgress; + + let prepared = prepare_sync_workspace( + SyncWorkspaceRequest { + config: &config, + workspace: &workspace, + refresh: false, + skip_uncommitted: true, + pull: false, + concurrency_override: None, + create_base_path: false, + }, + &progress, + ) + .await + .map_err(error_string)?; + + let outcome = execute_prepared_sync( + &prepared, + false, + Arc::new(NoCloneProgress), + Arc::new(NoSyncProgress), + ) + .await; + if outcome + .clone_summary + .as_ref() + .is_some_and(|summary| summary.failed > 0) + || outcome + .sync_summary + .as_ref() + .is_some_and(|summary| summary.failed > 0) + { + return Err("Sync completed with failures".to_string()); + } + + workspace.last_synced = Some(chrono::Utc::now().to_rfc3339()); + WorkspaceManager::save(&workspace).map_err(error_string)?; + Ok(()) +} + +pub(crate) fn read_status_snapshot() -> Result { + let ipc = IpcConfig::default_path()?; + ipc.ensure_dir()?; + let status_path = ipc.status_file_path(); + let writer = StatusFileWriter::new(status_path.clone()); + let metadata = fs::metadata(&status_path).ok(); + let updated_at = metadata + .as_ref() + .and_then(|meta| meta.modified().ok()) + .map(system_time_to_rfc3339); + let stale = metadata + .as_ref() + .and_then(|meta| meta.modified().ok()) + .map(|modified| { + modified + .elapsed() + .unwrap_or(Duration::from_secs(DAEMON_STALE_AFTER_SECS + 1)) + > Duration::from_secs(DAEMON_STALE_AFTER_SECS) + }) + .unwrap_or(true); + let status = if writer.exists() { + Some(writer.read()?) + } else { + None + }; + + Ok(StatusSnapshot { + status_path: status_path.display().to_string(), + updated_at, + stale, + status, + }) +} + +fn workspace_summary( + workspace: &WorkspaceConfig, + default_workspace: Option<&str>, +) -> WorkspaceSummary { + let collapsed = tilde_collapse_path(&workspace.root_path); + let root = workspace.root_path.display().to_string(); + let default = default_workspace + .map(|value| value == collapsed || same_path_string(value, &workspace.root_path)) + .unwrap_or(false); + + WorkspaceSummary { + id: collapsed, + root, + provider: workspace.provider.kind.display_name().to_string(), + org_count: workspace.orgs.len(), + last_sync: workspace.last_synced.clone(), + default, + } +} + +fn same_path_string(value: &str, path: &Path) -> bool { + let expanded = shellexpand::tilde(value); + Path::new(expanded.as_ref()) == path +} + +fn system_time_to_rfc3339(time: SystemTime) -> String { + let datetime: chrono::DateTime = time.into(); + datetime.to_rfc3339() +} + +fn error_string(error: impl std::fmt::Display) -> String { + error.to_string() +} diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs new file mode 100644 index 0000000..3eb5586 --- /dev/null +++ b/crates/git-same-app/src/main.rs @@ -0,0 +1,19 @@ +mod commands; +mod status_stream; + +fn main() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + commands::list_workspaces, + commands::read_status, + commands::start_sync, + ]) + .setup(|app| { + if let Err(error) = status_stream::spawn_watcher(app.handle().clone()) { + eprintln!("failed to start status watcher: {error}"); + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running git-Same"); +} diff --git a/crates/git-same-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs new file mode 100644 index 0000000..f75fbdc --- /dev/null +++ b/crates/git-same-app/src/status_stream.rs @@ -0,0 +1,44 @@ +use crate::commands::read_status_snapshot; +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use tauri::{AppHandle, Emitter}; + +pub fn spawn_watcher(app: AppHandle) -> anyhow::Result<()> { + let snapshot = read_status_snapshot()?; + let status_path = std::path::PathBuf::from(&snapshot.status_path); + let watch_path = status_path + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or(status_path); + + std::thread::Builder::new() + .name("git-same-status-watcher".to_string()) + .spawn(move || { + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = match RecommendedWatcher::new(tx, Config::default()) { + Ok(watcher) => watcher, + Err(error) => { + eprintln!("failed to create status watcher: {error}"); + return; + } + }; + + if let Err(error) = watcher.watch(&watch_path, RecursiveMode::NonRecursive) { + eprintln!( + "failed to watch status directory '{}': {error}", + watch_path.display() + ); + return; + } + + for event in rx { + if event.is_err() { + continue; + } + if let Ok(snapshot) = read_status_snapshot() { + let _ = app.emit("status-updated", snapshot); + } + } + })?; + + Ok(()) +} diff --git a/crates/git-same-app/tauri.conf.json b/crates/git-same-app/tauri.conf.json new file mode 100644 index 0000000..7ac43a4 --- /dev/null +++ b/crates/git-same-app/tauri.conf.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "git-Same", + "version": "3.1.0", + "identifier": "com.zaai.git-same", + "build": { + "beforeDevCommand": "corepack pnpm dev", + "beforeBuildCommand": "corepack pnpm build", + "devUrl": "http://localhost:1420", + "frontendDist": "ui/dist" + }, + "app": { + "windows": [ + { + "title": "git-Same", + "width": 1100, + "height": 720, + "minWidth": 820, + "minHeight": 560, + "decorations": true, + "fullscreen": false + } + ], + "security": { + "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'" + } + }, + "bundle": { + "active": true, + "targets": ["app", "dmg"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "macOS": { + "minimumSystemVersion": "13.0", + "signingIdentity": null, + "entitlements": null + } + } +} diff --git a/crates/git-same-app/ui/index.html b/crates/git-same-app/ui/index.html new file mode 100644 index 0000000..62e6b31 --- /dev/null +++ b/crates/git-same-app/ui/index.html @@ -0,0 +1,12 @@ + + + + + + git-Same + + +
+ + + diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json new file mode 100644 index 0000000..e996f9a --- /dev/null +++ b/crates/git-same-app/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "git-same-app-ui", + "private": true, + "version": "3.1.0", + "type": "module", + "packageManager": "pnpm@11.0.8", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 1420", + "build": "vite build", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@tauri-apps/api": "^2.9.0", + "lucide-svelte": "^0.468.0", + "svelte": "^5.0.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] + } +} diff --git a/crates/git-same-app/ui/pnpm-lock.yaml b/crates/git-same-app/ui/pnpm-lock.yaml new file mode 100644 index 0000000..5f18e27 --- /dev/null +++ b/crates/git-same-app/ui/pnpm-lock.yaml @@ -0,0 +1,1092 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tauri-apps/api': + specifier: ^2.9.0 + version: 2.11.0 + lucide-svelte: + specifier: ^0.468.0 + version: 0.468.0(svelte@5.55.5) + svelte: + specifier: ^5.0.0 + version: 5.55.5 + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.55.5)(vite@6.4.2) + '@tauri-apps/cli': + specifier: ^2.9.0 + version: 2.11.1 + svelte-check: + specifier: ^4.0.0 + version: 4.4.8(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.2 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + + '@tauri-apps/cli-darwin-arm64@2.11.1': + resolution: {integrity: sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.1': + resolution: {integrity: sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.1': + resolution: {integrity: sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.1': + resolution: {integrity: sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.1': + resolution: {integrity: sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.1': + resolution: {integrity: sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.1': + resolution: {integrity: sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.1': + resolution: {integrity: sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.1': + resolution: {integrity: sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.1': + resolution: {integrity: sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.1': + resolution: {integrity: sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.1': + resolution: {integrity: sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==} + engines: {node: '>= 10'} + hasBin: true + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + devalue@5.8.0: + resolution: {integrity: sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.6: + resolution: {integrity: sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + lucide-svelte@0.468.0: + resolution: {integrity: sha512-n0ecAFtCY5LEeL+PJ1Xj4n3c2gzj8tMpak0KMGnvoSJEjCsCnRB0mekBtJZAo7beyynW9Qj5Um1KfMBAeTNplw==} + peerDependencies: + svelte: ^3 || ^4 || ^5.0.0-next.42 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + svelte-check@4.4.8: + resolution: {integrity: sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte@5.55.5: + resolution: {integrity: sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.5)(vite@6.4.2))(svelte@5.55.5)(vite@6.4.2)': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.55.5)(vite@6.4.2) + debug: 4.4.3 + svelte: 5.55.5 + vite: 6.4.2 + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.5)(vite@6.4.2)': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.5)(vite@6.4.2))(svelte@5.55.5)(vite@6.4.2) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.55.5 + vite: 6.4.2 + vitefu: 1.1.3(vite@6.4.2) + transitivePeerDependencies: + - supports-color + + '@tauri-apps/api@2.11.0': {} + + '@tauri-apps/cli-darwin-arm64@2.11.1': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.1': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.1': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.1': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.1': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.1': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.1': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.1': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.1': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.1': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.1': + optional: true + + '@tauri-apps/cli@2.11.1': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.1 + '@tauri-apps/cli-darwin-x64': 2.11.1 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.1 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.1 + '@tauri-apps/cli-linux-arm64-musl': 2.11.1 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.1 + '@tauri-apps/cli-linux-x64-gnu': 2.11.1 + '@tauri-apps/cli-linux-x64-musl': 2.11.1 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.1 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.1 + '@tauri-apps/cli-win32-x64-msvc': 2.11.1 + + '@types/estree@1.0.8': {} + + '@types/trusted-types@2.0.7': {} + + acorn@8.16.0: {} + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge@4.3.1: {} + + devalue@5.8.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esm-env@1.2.2: {} + + esrap@2.2.6: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + kleur@4.1.5: {} + + locate-character@3.0.0: {} + + lucide-svelte@0.468.0(svelte@5.55.5): + dependencies: + svelte: 5.55.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mri@1.2.0: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + source-map-js@1.2.1: {} + + svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.55.5 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte@5.55.5: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.0 + esm-env: 1.2.2 + esrap: 2.2.6 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + typescript@5.9.3: {} + + vite@6.4.2: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + fsevents: 2.3.3 + + vitefu@1.1.3(vite@6.4.2): + optionalDependencies: + vite: 6.4.2 + + zimmerframe@1.1.4: {} diff --git a/crates/git-same-app/ui/pnpm-workspace.yaml b/crates/git-same-app/ui/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/crates/git-same-app/ui/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/crates/git-same-app/ui/src/App.svelte b/crates/git-same-app/ui/src/App.svelte new file mode 100644 index 0000000..527f630 --- /dev/null +++ b/crates/git-same-app/ui/src/App.svelte @@ -0,0 +1,564 @@ + + + + git-Same + + +
+ + +
+
+
+

{activeView === 'settings' ? 'Settings' : currentWorkspace?.id ?? 'Dashboard'}

+

{currentWorkspace?.root ?? snapshot?.status_path ?? 'No workspace selected'}

+
+
+ + {#if activeView === 'dashboard'} + + {/if} +
+
+ + {#if error} + + {:else if snapshot?.stale} + + {/if} + + {#if activeView === 'settings'} +
+

Finder Badges

+
+
+
Status file
+
{snapshot?.status_path ?? 'Unavailable'}
+
+
+
Last update
+
{relativeTime(snapshot?.updated_at)}
+
+
+
Daemon PID
+
{snapshot?.status?.daemon_pid ?? 'Unavailable'}
+
+
+
+ {:else} +
+
{counts.total}Total
+
{counts.green}Synced
+
{counts.blue}Local config
+
{counts.orange}Branches
+
{counts.red}Local work
+
+ +
+
+ Repository + State + Branch + Changes + Remote +
+ {#if workspaceRepos.length === 0} +
No status rows
+ {:else} + {#each workspaceRepos.slice(0, 200) as repo} +
+
+ {repoName(repo.path)} + {repo.org ?? repo.workspace ?? repo.path} +
+ {badgeLabel(repo.badge)} + {repo.current_branch} + {repo.staged_count + repo.unstaged_count + repo.untracked_count} + {repo.ahead} ahead / {repo.behind} behind +
+ {/each} + {/if} +
+ {/if} +
+
+ + diff --git a/crates/git-same-app/ui/src/lib/tauri.ts b/crates/git-same-app/ui/src/lib/tauri.ts new file mode 100644 index 0000000..84d1bbb --- /dev/null +++ b/crates/git-same-app/ui/src/lib/tauri.ts @@ -0,0 +1,19 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import type { StatusSnapshot, WorkspaceSummary } from './types'; + +export function listWorkspaces(): Promise { + return invoke('list_workspaces'); +} + +export function readStatus(): Promise { + return invoke('read_status'); +} + +export function startSync(workspaceId: string): Promise { + return invoke('start_sync', { workspaceId }); +} + +export function onStatusUpdated(callback: (snapshot: StatusSnapshot) => void) { + return listen('status-updated', (event) => callback(event.payload)); +} diff --git a/crates/git-same-app/ui/src/lib/types.ts b/crates/git-same-app/ui/src/lib/types.ts new file mode 100644 index 0000000..b208a5c --- /dev/null +++ b/crates/git-same-app/ui/src/lib/types.ts @@ -0,0 +1,54 @@ +export type Badge = 'green' | 'blue' | 'orange' | 'red' | 'gray'; + +export interface WorkspaceSummary { + id: string; + root: string; + provider: string; + org_count: number; + last_sync: string | null; + default: boolean; +} + +export interface FinderWorkspaceInfo { + name: string; + root: string; + orgs: string[]; +} + +export interface FinderRepoStatus { + path: string; + workspace?: string; + org?: string; + badge: Badge; + current_branch: string; + default_branch?: string; + commit_count: number; + staged_count: number; + unstaged_count: number; + untracked_count: number; + ahead: number; + behind: number; + stash_count: number; + has_important_ignored_files: boolean; + important_ignored_files?: string[]; + all_branches_synced: boolean; + all_worktrees_synced: boolean; + read_error?: string; +} + +export interface FinderStatus { + version: number; + timestamp: string; + daemon_pid: number; + workspaces: FinderWorkspaceInfo[]; + custom_folders?: string[]; + repos: FinderRepoStatus[]; + monitored_roots?: string[]; +} + +export interface StatusSnapshot { + status_path: string; + updated_at: string | null; + stale: boolean; + status: FinderStatus | null; +} diff --git a/crates/git-same-app/ui/src/main.ts b/crates/git-same-app/ui/src/main.ts new file mode 100644 index 0000000..19f4e49 --- /dev/null +++ b/crates/git-same-app/ui/src/main.ts @@ -0,0 +1,9 @@ +import './styles/tokens.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; + +const app = mount(App, { + target: document.getElementById('app')!, +}); + +export default app; diff --git a/crates/git-same-app/ui/src/styles/tokens.css b/crates/git-same-app/ui/src/styles/tokens.css new file mode 100644 index 0000000..cbd6a7e --- /dev/null +++ b/crates/git-same-app/ui/src/styles/tokens.css @@ -0,0 +1,57 @@ +:root { + color-scheme: light dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #f7f5ef; + color: #1f2528; + font-synthesis: none; + text-rendering: optimizeLegibility; + --bg: #f7f5ef; + --panel: #ffffff; + --panel-alt: #eef3f1; + --line: #d7ded9; + --text: #1f2528; + --muted: #687276; + --accent: #0f766e; + --accent-strong: #0b5f59; + --danger: #ba3f38; + --warning: #b66a12; + --ok: #2f7d50; + --blue: #236fb1; + --shadow: 0 12px 28px rgba(32, 38, 35, 0.12); +} + +@media (prefers-color-scheme: dark) { + :root { + background: #111719; + color: #edf1ef; + --bg: #111719; + --panel: #192123; + --panel-alt: #222d2c; + --line: #33403f; + --text: #edf1ef; + --muted: #a8b2b0; + --accent: #4db6ac; + --accent-strong: #73d6cb; + --danger: #ff7f76; + --warning: #e6a23c; + --ok: #6ec48e; + --blue: #79aee8; + --shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--bg); +} + +button { + font: inherit; +} diff --git a/crates/git-same-app/ui/tsconfig.json b/crates/git-same-app/ui/tsconfig.json new file mode 100644 index 0000000..64ff596 --- /dev/null +++ b/crates/git-same-app/ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2022", + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte"] +} diff --git a/crates/git-same-app/ui/tsconfig.node.json b/crates/git-same-app/ui/tsconfig.node.json new file mode 100644 index 0000000..3b9e402 --- /dev/null +++ b/crates/git-same-app/ui/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "target": "ES2022" + }, + "include": ["vite.config.ts"] +} diff --git a/crates/git-same-app/ui/vite.config.ts b/crates/git-same-app/ui/vite.config.ts new file mode 100644 index 0000000..11672c3 --- /dev/null +++ b/crates/git-same-app/ui/vite.config.ts @@ -0,0 +1,12 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [svelte()], + clearScreen: false, + server: { + host: '127.0.0.1', + port: 1420, + strictPort: true, + }, +}); diff --git a/crates/git-same-cli/src/commands/sync_cmd_tests.rs b/crates/git-same-cli/src/commands/sync_cmd_tests.rs index f676977..db3c096 100644 --- a/crates/git-same-cli/src/commands/sync_cmd_tests.rs +++ b/crates/git-same-cli/src/commands/sync_cmd_tests.rs @@ -1,8 +1,5 @@ use super::*; use git_same_core::output::{Output, Verbosity}; -use tokio::sync::Mutex; - -static HOME_LOCK: Mutex<()> = Mutex::const_new(()); fn default_args() -> SyncCmdArgs { SyncCmdArgs { @@ -17,7 +14,7 @@ fn default_args() -> SyncCmdArgs { #[tokio::test] async fn run_returns_error_when_no_workspace_is_configured() { - let _lock = HOME_LOCK.lock().await; + let _lock = crate::test_support::ENV_LOCK.lock().await; let original_home = std::env::var("HOME").ok(); let temp = tempfile::tempdir().unwrap(); std::env::set_var("HOME", temp.path()); @@ -53,7 +50,7 @@ async fn run_returns_error_when_no_workspace_is_configured() { #[tokio::test] async fn run_returns_error_for_unknown_workspace_name() { - let _lock = HOME_LOCK.lock().await; + let _lock = crate::test_support::ENV_LOCK.lock().await; let original_home = std::env::var("HOME").ok(); let temp = tempfile::tempdir().unwrap(); std::env::set_var("HOME", temp.path()); diff --git a/crates/git-same-cli/src/lib.rs b/crates/git-same-cli/src/lib.rs index a8e14a4..88ddaef 100644 --- a/crates/git-same-cli/src/lib.rs +++ b/crates/git-same-cli/src/lib.rs @@ -10,5 +10,9 @@ pub mod cli; pub mod commands; #[cfg(feature = "tui")] pub mod setup; +#[cfg(test)] +pub(crate) mod test_support { + pub static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +} #[cfg(feature = "tui")] pub mod tui; diff --git a/crates/git-same-cli/src/setup/handler_tests.rs b/crates/git-same-cli/src/setup/handler_tests.rs index a695071..24f09a8 100644 --- a/crates/git-same-cli/src/setup/handler_tests.rs +++ b/crates/git-same-cli/src/setup/handler_tests.rs @@ -13,6 +13,10 @@ fn tempdir_in_cwd(prefix: &str) -> tempfile::TempDir { .unwrap() } +async fn lock_process_env() -> tokio::sync::MutexGuard<'static, ()> { + crate::test_support::ENV_LOCK.lock().await +} + fn find_entry_index(state: &SetupState, path: &std::path::Path) -> usize { let wanted = super::tilde_collapse(&path.to_string_lossy()); state @@ -153,6 +157,7 @@ async fn enter_in_suggestions_mode_does_not_change_base_path() { #[tokio::test] async fn b_opens_path_browser_from_suggestions_mode() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-browse-"); std::fs::create_dir_all(temp.path().join("child")).unwrap(); @@ -207,6 +212,7 @@ async fn left_on_root_moves_popup_to_parent_directory() { #[tokio::test] async fn right_in_path_browse_mode_navigates_tree_without_advancing_step() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-nav-"); let alpha = temp.path().join("alpha"); std::fs::create_dir_all(&alpha).unwrap(); @@ -246,6 +252,7 @@ async fn right_in_path_browse_mode_navigates_tree_without_advancing_step() { #[tokio::test] async fn enter_in_browse_mode_sets_path_and_closes_popup() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-enter-"); let alpha = temp.path().join("alpha"); std::fs::create_dir_all(&alpha).unwrap(); @@ -317,6 +324,7 @@ async fn esc_in_popup_only_closes_popup() { #[tokio::test] async fn left_moves_to_parent_and_then_collapses() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-left-"); let alpha = temp.path().join("alpha"); let nested = alpha.join("nested"); @@ -362,6 +370,7 @@ async fn left_moves_to_parent_and_then_collapses() { #[tokio::test] async fn right_on_leaf_does_not_change_selection_until_enter() { + let _env_lock = lock_process_env().await; let leaf_temp = tempdir_in_cwd("gisa-path-leaf-"); let expected = super::tilde_collapse(&leaf_temp.path().to_string_lossy()); @@ -393,6 +402,7 @@ async fn right_on_leaf_does_not_change_selection_until_enter() { #[tokio::test] async fn very_large_directory_list_is_loaded() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-many-"); for i in 0..150 { std::fs::create_dir_all(temp.path().join(format!("d{i:03}"))).unwrap(); @@ -687,6 +697,7 @@ async fn space_toggles_org_selection() { /// Up at path_browse_index == 0 must not move (underflow guard). #[tokio::test] async fn up_at_first_browse_entry_does_not_move() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-hbrowse-up-"); let alpha = temp.path().join("a-dir"); std::fs::create_dir_all(&alpha).unwrap(); @@ -713,6 +724,7 @@ async fn up_at_first_browse_entry_does_not_move() { /// Down at the last browse entry must not move past the end. #[tokio::test] async fn down_at_last_browse_entry_does_not_move() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-hbrowse-dn-"); let alpha = temp.path().join("only-child"); std::fs::create_dir_all(&alpha).unwrap(); diff --git a/crates/git-same-core/src/config/parser.rs b/crates/git-same-core/src/config/parser.rs index 210ca54..e1cf5dc 100644 --- a/crates/git-same-core/src/config/parser.rs +++ b/crates/git-same-core/src/config/parser.rs @@ -336,7 +336,7 @@ show_ambient = true # Roots to walk for ambient repos. "~" expands to your home directory. # If you change this, update the FinderSync extension entitlements -# (macos/GitSameBadgeSync/GitSameBadgeSync.entitlements) and re-sign. +# (macos/GitSameBadges/GitSameBadges.entitlements) and re-sign. scan_roots = ["~"] # Maximum directory depth for the ambient walk. diff --git a/crates/git-same-core/src/types/finder_status.rs b/crates/git-same-core/src/types/finder_status.rs index d533391..e819f9d 100644 --- a/crates/git-same-core/src/types/finder_status.rs +++ b/crates/git-same-core/src/types/finder_status.rs @@ -128,7 +128,7 @@ pub struct FinderWorkspaceInfo { /// Top-level status file written by the daemon. /// /// This is the single source of truth read by the FinderSync extension. -/// Written atomically to `~/.config/git-same/finder/status.json`. +/// Written atomically to the platform-default Finder IPC status path. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FinderStatus { pub version: u32, diff --git a/macos/com.zaai.git-same.daemon.plist b/macos/com.zaai.git-same.daemon.plist index 0f9052d..0458159 100644 --- a/macos/com.zaai.git-same.daemon.plist +++ b/macos/com.zaai.git-same.daemon.plist @@ -7,7 +7,7 @@ com.zaai.git-same.daemon ProgramArguments - /usr/local/bin/git-same + __GIT_SAME_DAEMON_BINARY__ daemon --foreground diff --git a/toolkit/homebrew/cask.rb.tmpl b/toolkit/homebrew/cask.rb.tmpl index b4bbd41..267ca3b 100644 --- a/toolkit/homebrew/cask.rb.tmpl +++ b/toolkit/homebrew/cask.rb.tmpl @@ -11,9 +11,9 @@ cask "git-same" do sha256 arm: "SHA_AARCH64_PLACEHOLDER", intel: "SHA_X86_64_PLACEHOLDER" - url "https://github.com/zaai-com/git-same/releases/download/#{version}/git-same-#{version}-#{arch}-apple-darwin.tar.gz" - name "Git-Same" - desc "Discover and mirror GitHub org/repo structures locally" + url "https://github.com/zaai-com/git-same/releases/download/#{version}/git-same-#{version}-#{arch}.dmg" + name "git-Same" + desc "GitHub org mirror with parallel clone, sync, and Finder badges" homepage "https://github.com/zaai-com/git-same" livecheck do @@ -21,23 +21,36 @@ cask "git-same" do strategy :github_latest end - depends_on macos: ">= :big_sur" - - # Casks don't have first-class shell-completion stanzas, so completions go - # through `binary` with absolute target: paths matching the locations the - # headless `git-same-cli` formula installs to. All `binary` stanzas must be - # grouped together per Cask/StanzaOrder; the manpage stanza follows. - binary "git-same" - binary "git-same", target: "gitsame" - binary "git-same", target: "gitsa" - binary "git-same", target: "gisa" - binary "_git-same", target: "#{HOMEBREW_PREFIX}/share/zsh/site-functions/_git-same" - binary "git-same.bash", target: "#{HOMEBREW_PREFIX}/etc/bash_completion.d/git-same" - binary "git-same.fish", target: "#{HOMEBREW_PREFIX}/share/fish/vendor_completions.d/git-same.fish" - manpage "git-same.1" + depends_on macos: ">= :ventura" + + app "git-same.app" + + binary "#{appdir}/git-same.app/Contents/Helpers/git-same" + binary "#{appdir}/git-same.app/Contents/Helpers/git-same", target: "gitsame" + binary "#{appdir}/git-same.app/Contents/Helpers/git-same", target: "gitsa" + binary "#{appdir}/git-same.app/Contents/Helpers/git-same", target: "gisa" + + postflight do + plist_src = "#{appdir}/git-same.app/Contents/Resources/com.zaai.git-same.daemon.plist" + plist_dst = "#{Dir.home}/Library/LaunchAgents/com.zaai.git-same.daemon.plist" + daemon_binary = "#{appdir}/git-same.app/Contents/Helpers/git-same" + + FileUtils.mkdir_p(File.dirname(plist_dst)) + rendered = File.read(plist_src).gsub("__GIT_SAME_DAEMON_BINARY__", daemon_binary) + File.write(plist_dst, rendered) + system_command "/bin/launchctl", args: ["unload", plist_dst], sudo: false, must_succeed: false + system_command "/bin/launchctl", args: ["load", plist_dst], sudo: false, must_succeed: false + end + + uninstall launchctl: "com.zaai.git-same.daemon", + delete: ["~/Library/LaunchAgents/com.zaai.git-same.daemon.plist"] zap trash: [ "~/.config/git-same", + "~/Library/Application Support/com.zaai.git-same", + "~/Library/Caches/com.zaai.git-same", "~/Library/Caches/git-same", + "~/Library/Group Containers/group.57KL6Y7V32.com.zaai.git-same", + "~/Library/LaunchAgents/com.zaai.git-same.daemon.plist", ] end diff --git a/toolkit/homebrew/render-cask.sh b/toolkit/homebrew/render-cask.sh index af02517..16600bd 100755 --- a/toolkit/homebrew/render-cask.sh +++ b/toolkit/homebrew/render-cask.sh @@ -30,8 +30,8 @@ usage() { Usage: $0 VERSION --sha-arm --sha-intel [--out PATH] VERSION Strict semver, no leading zeros, no v prefix (e.g. 3.0.1) - --sha-arm SHA256 of the aarch64 tarball (64 hex chars) - --sha-intel SHA256 of the x86_64 tarball (64 hex chars) + --sha-arm SHA256 of the aarch64 DMG (64 hex chars) + --sha-intel SHA256 of the x86_64 DMG (64 hex chars) --out PATH Write to PATH instead of stdout EOF } diff --git a/toolkit/packaging/macos/build-app-bundle.sh b/toolkit/packaging/macos/build-app-bundle.sh new file mode 100755 index 0000000..a387519 --- /dev/null +++ b/toolkit/packaging/macos/build-app-bundle.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Build the git-same macOS app bundle and DMG. + +set -euo pipefail + +ROOT="${WORKSPACE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" +VERSION="${VERSION:-}" +ARCH="${ARCH:-}" +OUTPUT_DIR="${OUTPUT_DIR:-$ROOT/dist/macos}" +INCLUDE_FINDER_EXTENSION="${INCLUDE_FINDER_EXTENSION:-0}" +SKIP_SIGNING="${SKIP_SIGNING:-0}" +SKIP_NOTARIZATION="${SKIP_NOTARIZATION:-0}" + +usage() { + cat <&2 +Required env vars: + VERSION Strict semver, e.g. 3.1.0 + ARCH aarch64 or x86_64 + +Optional env vars: + WORKSPACE_ROOT Repo root (default: auto-detected) + OUTPUT_DIR Artifact output directory (default: dist/macos) + INCLUDE_FINDER_EXTENSION 1 to embed GitSameBadges.appex, 0 for D-App + SKIP_SIGNING 1 to build unsigned app/dmg for local smoke tests + SKIP_NOTARIZATION 1 to sign without notarytool/stapler +EOF +} + +if [ -z "$VERSION" ] || [ -z "$ARCH" ]; then + usage + exit 2 +fi +if ! [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "ERROR: VERSION must be strict semver, got '$VERSION'" >&2 + exit 2 +fi +case "$ARCH" in + aarch64|x86_64) ;; + *) echo "ERROR: ARCH must be aarch64 or x86_64, got '$ARCH'" >&2; exit 2 ;; +esac + +TARGET="${ARCH}-apple-darwin" +BUILD_ROOT="$OUTPUT_DIR/build-${ARCH}" +APP="$OUTPUT_DIR/git-same.app" +DMG="$OUTPUT_DIR/git-same-${VERSION}-${ARCH}.dmg" +SIGN_SCRIPT="$ROOT/toolkit/packaging/macos/sign-app-bundle.sh" + +mkdir -p "$OUTPUT_DIR" +rm -rf "$BUILD_ROOT" "$APP" "$DMG" +mkdir -p "$BUILD_ROOT" + +echo "==> Building CLI ($TARGET)" +( cd "$ROOT" && cargo build --release --target "$TARGET" -p git-same ) + +echo "==> Installing frontend dependencies" +if command -v corepack >/dev/null 2>&1; then + corepack enable pnpm +fi +PNPM=(pnpm) +if ! command -v pnpm >/dev/null 2>&1; then + PNPM=(corepack pnpm) +fi +( cd "$ROOT/crates/git-same-app" && "${PNPM[@]}" --dir ui install --frozen-lockfile ) + +echo "==> Building Tauri app binary ($TARGET, no bundle)" +TAURI_CLI="$ROOT/crates/git-same-app/ui/node_modules/.bin/tauri" +if [ ! -x "$TAURI_CLI" ]; then + echo "ERROR: Tauri CLI not found at $TAURI_CLI" >&2 + exit 1 +fi +( cd "$ROOT/crates/git-same-app" && "$TAURI_CLI" build --target "$TARGET" --no-bundle ) + +echo "==> Assembling app bundle" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Helpers" "$APP/Contents/Resources" "$APP/Contents/PlugIns" +cp "$ROOT/target/$TARGET/release/git-same-app" "$APP/Contents/MacOS/git-same-app" +cp "$ROOT/target/$TARGET/release/git-same" "$APP/Contents/Helpers/git-same" +cp "$ROOT/macos/com.zaai.git-same.daemon.plist" "$APP/Contents/Resources/com.zaai.git-same.daemon.plist" +cp "$ROOT/crates/git-same-app/icons/icon.icns" "$APP/Contents/Resources/icons.icns" +chmod +x "$APP/Contents/MacOS/git-same-app" "$APP/Contents/Helpers/git-same" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleExecutablegit-same-app + CFBundleIconFileicons.icns + CFBundleIdentifiercom.zaai.git-same + CFBundleNamegit-Same + CFBundleDisplayNamegit-Same + CFBundleVersion${VERSION} + CFBundleShortVersionString${VERSION} + CFBundlePackageTypeAPPL + LSMinimumSystemVersion13.0 + LSApplicationCategoryTypepublic.app-category.developer-tools + NSHighResolutionCapable + + +EOF + +if [ "$INCLUDE_FINDER_EXTENSION" = "1" ]; then + echo "==> Building FinderSync extension" + xcodebuild \ + -project "$ROOT/macos/GitSameBadges.xcodeproj" \ + -scheme GitSameBadges \ + -configuration Release \ + -destination "generic/platform=macOS" \ + SYMROOT="$BUILD_ROOT/xcode-products" \ + OBJROOT="$BUILD_ROOT/xcode-obj" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build + + APPEX="$BUILD_ROOT/xcode-products/Release/GitSameBadges.appex" + if [ ! -d "$APPEX" ]; then + echo "ERROR: FinderSync extension product not found at $APPEX" >&2 + exit 1 + fi + cp -R "$APPEX" "$APP/Contents/PlugIns/" +fi + +if [ "$SKIP_SIGNING" != "1" ]; then + SIGN_ARGS=() + if [ "$SKIP_NOTARIZATION" = "1" ]; then + SIGN_ARGS+=(--skip-notarization) + fi + bash "$SIGN_SCRIPT" "$APP" "${SIGN_ARGS[@]}" +fi + +echo "==> Creating DMG" +if command -v create-dmg >/dev/null 2>&1; then + create-dmg \ + --volname "git-Same ${VERSION}" \ + --window-size 540 380 \ + --icon-size 100 \ + --icon "git-same.app" 140 190 \ + --app-drop-link 400 190 \ + "$DMG" \ + "$APP" +else + DMG_ROOT="$BUILD_ROOT/dmg-root" + mkdir -p "$DMG_ROOT" + cp -R "$APP" "$DMG_ROOT/" + ln -s /Applications "$DMG_ROOT/Applications" + hdiutil create -volname "git-Same ${VERSION}" -srcfolder "$DMG_ROOT" -ov -format UDZO "$DMG" +fi + +if [ "$SKIP_SIGNING" != "1" ]; then + SIGN_ARGS=(--skip-app --dmg "$DMG") + if [ "$SKIP_NOTARIZATION" = "1" ]; then + SIGN_ARGS+=(--skip-notarization) + fi + bash "$SIGN_SCRIPT" "$APP" "${SIGN_ARGS[@]}" +fi + +shasum -a 256 "$DMG" | awk '{print $1}' > "$DMG.sha256" + +echo "==> Done" +echo " app: $APP" +echo " dmg: $DMG" +echo " sha256: $(cat "$DMG.sha256")" diff --git a/toolkit/packaging/macos/sign-app-bundle.sh b/toolkit/packaging/macos/sign-app-bundle.sh new file mode 100755 index 0000000..2169a7d --- /dev/null +++ b/toolkit/packaging/macos/sign-app-bundle.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Sign, verify, notarize, and staple a git-same macOS app bundle and, +# optionally, its DMG. +# +# Usage: +# sign-app-bundle.sh APP_PATH [--dmg DMG_PATH] [--skip-app] [--skip-notarization] + +set -euo pipefail + +APP_PATH="" +DMG_PATH="" +SKIP_APP=0 +SKIP_NOTARIZATION=0 + +usage() { + cat <&2 +Usage: $0 APP_PATH [--dmg DMG_PATH] [--skip-app] [--skip-notarization] + +Required env vars: + APPLE_DEVELOPER_CERTIFICATE_P12 + APPLE_DEVELOPER_CERTIFICATE_PASSWORD + APPLE_SIGNING_IDENTITY + APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_KEYCHAIN_PASSWORD +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dmg) DMG_PATH="${2:-}"; shift 2 ;; + --skip-app) SKIP_APP=1; shift ;; + --skip-notarization) SKIP_NOTARIZATION=1; shift ;; + -h|--help) usage; exit 0 ;; + --*) echo "ERROR: unknown flag $1" >&2; usage; exit 2 ;; + *) + if [ -z "$APP_PATH" ]; then + APP_PATH="$1"; shift + else + echo "ERROR: unexpected positional arg $1" >&2; usage; exit 2 + fi + ;; + esac +done + +if [ -z "$APP_PATH" ]; then + echo "ERROR: APP_PATH is required" >&2; usage; exit 2 +fi +if [ ! -d "$APP_PATH" ]; then + echo "ERROR: app bundle not found: $APP_PATH" >&2; exit 1 +fi +if [ -n "$DMG_PATH" ] && [ ! -f "$DMG_PATH" ]; then + echo "ERROR: DMG not found: $DMG_PATH" >&2; exit 1 +fi + +for var in APPLE_DEVELOPER_CERTIFICATE_P12 APPLE_DEVELOPER_CERTIFICATE_PASSWORD \ + APPLE_SIGNING_IDENTITY APPLE_ID APPLE_TEAM_ID \ + APPLE_APP_SPECIFIC_PASSWORD APPLE_KEYCHAIN_PASSWORD; do + if [ -z "${!var:-}" ]; then + echo "ERROR: required env var $var is not set" >&2 + exit 1 + fi +done + +APP_ABS="$(cd "$(dirname "$APP_PATH")" && pwd)/$(basename "$APP_PATH")" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +IDENTITY="Developer ID Application: ${APPLE_SIGNING_IDENTITY}" +KEYCHAIN_NAME="git-same-app-build-$$.keychain" +KEYCHAIN_PATH="$HOME/Library/Keychains/${KEYCHAIN_NAME}-db" +CERT_DIR="$(mktemp -d -t git-same-app-cert.XXXXXX)" +NOTARY_DIR="$(mktemp -d -t git-same-app-notary.XXXXXX)" +CERT_FILE="$CERT_DIR/cert.p12" + +cleanup() { + rm -rf "$CERT_DIR" "$NOTARY_DIR" || true + if security list-keychains | grep -q "$KEYCHAIN_NAME"; then + security delete-keychain "$KEYCHAIN_NAME" || true + fi + rm -f "$KEYCHAIN_PATH" || true +} +trap cleanup EXIT + +echo "==> Creating temp keychain" +security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" +security set-keychain-settings -lut 21600 "$KEYCHAIN_NAME" +security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" +security list-keychains -d user -s "$KEYCHAIN_NAME" "$(security list-keychains -d user | tr -d '"')" + +echo "==> Importing Developer ID certificate" +echo "$APPLE_DEVELOPER_CERTIFICATE_P12" | base64 -D > "$CERT_FILE" +security import "$CERT_FILE" \ + -k "$KEYCHAIN_NAME" \ + -P "$APPLE_DEVELOPER_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security +security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k "$APPLE_KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_NAME" >/dev/null + +sign_app() { + echo "==> Signing app bundle inside-out" + /usr/bin/codesign --force --options runtime --timestamp \ + --sign "$IDENTITY" \ + "$APP_ABS/Contents/Helpers/git-same" + + if [ -d "$APP_ABS/Contents/PlugIns/GitSameBadges.appex" ]; then + /usr/bin/codesign --force --options runtime --timestamp \ + --sign "$IDENTITY" \ + --entitlements "$ROOT/macos/GitSameBadges/GitSameBadges.entitlements" \ + "$APP_ABS/Contents/PlugIns/GitSameBadges.appex" + fi + + /usr/bin/codesign --force --options runtime --timestamp \ + --sign "$IDENTITY" \ + --entitlements "$ROOT/crates/git-same-app/entitlements.plist" \ + "$APP_ABS/Contents/MacOS/git-same-app" + + /usr/bin/codesign --force --options runtime --timestamp \ + --sign "$IDENTITY" \ + --entitlements "$ROOT/crates/git-same-app/entitlements.plist" \ + "$APP_ABS" + + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_ABS" + /usr/sbin/spctl --assess --type execute --verbose "$APP_ABS" +} + +notarize_app() { + local zip_path="$NOTARY_DIR/$(basename "$APP_ABS").zip" + echo "==> Zipping app for notarization" + /usr/bin/ditto -c -k --keepParent "$APP_ABS" "$zip_path" + echo "==> Submitting app to notarytool" + xcrun notarytool submit "$zip_path" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --wait \ + --timeout 1200 + xcrun stapler staple "$APP_ABS" + xcrun stapler validate "$APP_ABS" +} + +sign_dmg() { + echo "==> Signing DMG" + /usr/bin/codesign --force --timestamp --sign "$IDENTITY" "$DMG_PATH" + if [ "$SKIP_NOTARIZATION" -eq 0 ]; then + echo "==> Submitting DMG to notarytool" + xcrun notarytool submit "$DMG_PATH" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --wait \ + --timeout 1200 + xcrun stapler staple "$DMG_PATH" + fi +} + +if [ "$SKIP_APP" -eq 0 ]; then + sign_app + if [ "$SKIP_NOTARIZATION" -eq 0 ]; then + notarize_app + fi +fi + +if [ -n "$DMG_PATH" ]; then + sign_dmg +fi From 88a75393a047b1d262afa5cf3896f9d4ac89b644 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 01:28:30 +0200 Subject: [PATCH 38/89] Document Tauri app dev setup and ignore Tauri build artifacts Adds a "Running the macOS App in development" section to docs/DEVELOPMENT.md so contributors can run cargo tauri dev against crates/git-same-app/ without hunting for the entry point. Tightens .gitignore with patterns for Tauri's local cache (.tauri/), Vite/SvelteKit caches under ui/, the pnpm fallback store, and stray *.dmg / *.app.zip outputs that can land outside /target/ when bundlers are run locally. --- .gitignore | 8 ++++++++ docs/DEVELOPMENT.md | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/.gitignore b/.gitignore index eaa64b9..b548aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,14 @@ node_modules/ dist/ crates/git-same-app/gen/ +.pnpm-store/ + +# Tauri local cache & bundle outputs outside of /target/ +.tauri/ +crates/git-same-app/ui/.svelte-kit/ +crates/git-same-app/ui/.vite/ +*.dmg +*.app.zip # IDE .vscode/ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index bcc0f16..219c715 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -70,6 +70,20 @@ cargo build --release --workspace The repository is a Cargo workspace with two member crates: `git-same-core` (engine library, `crates/git-same-core/`) and `git-same` (the CLI binary + TUI, `crates/git-same-cli/` on disk). The release binary is output at the workspace level: `target/release/git-same` (or `target/debug/git-same`). Alias symlinks are created by the install scripts, not by Cargo. +## Running the macOS App in development + +The Tauri-based desktop app lives at `crates/git-same-app/`. You need [pnpm](https://pnpm.io/) and the [`tauri-cli`](https://v2.tauri.app/reference/cli/) (`cargo install tauri-cli --version "^2.0"`). + +```bash +# Install frontend dependencies +pnpm --dir crates/git-same-app/ui install + +# Start the dev server (Vite + Rust backend with hot reload) +cargo tauri dev --manifest-path crates/git-same-app/Cargo.toml +``` + +The window opens with the workspace dashboard, reading from `~/.config/git-same/config.toml`. The app subscribes to the daemon's `status.json`, so updates from `git-same sync` (run in another terminal) appear live. + ## Running tests ```bash From 9665be8c356fee2e2143ff7a89faf14e8bc28686 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 01:41:40 +0200 Subject: [PATCH 39/89] Boot Tauri app from Conductor setup/run scripts to cover full stack setup.sh now checks Node + Corepack, installs the Tauri app's frontend dependencies via pnpm, and verifies the local Tauri CLI is runnable. run.sh installs the CLI/TUI as before, then execs `tauri dev` so Conductor brings up the GUI alongside the CLI in one command. --- toolkit/conductor/run.sh | 15 +++++++++++++ toolkit/conductor/setup.sh | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/toolkit/conductor/run.sh b/toolkit/conductor/run.sh index 6bad3f5..517675b 100755 --- a/toolkit/conductor/run.sh +++ b/toolkit/conductor/run.sh @@ -129,3 +129,18 @@ echo "" echo " $GS_COMMAND -v sync --dry-run" echo " $GS_COMMAND --json status" echo "" + +# Launch the Tauri desktop app in dev mode +TAURI_CLI="$PROJECT_DIR/crates/git-same-app/ui/node_modules/.bin/tauri" +if [ ! -x "$TAURI_CLI" ]; then + echo "ERROR: Tauri CLI not found at $TAURI_CLI" + echo "Run ./toolkit/conductor/setup.sh first to install frontend dependencies." + exit 1 +fi + +echo "========================================" +echo " Launching Tauri app (dev mode)" +echo "========================================" +echo "" +cd "$PROJECT_DIR" +exec "$TAURI_CLI" dev --manifest-path crates/git-same-app/Cargo.toml diff --git a/toolkit/conductor/setup.sh b/toolkit/conductor/setup.sh index b465de8..c708038 100755 --- a/toolkit/conductor/setup.sh +++ b/toolkit/conductor/setup.sh @@ -58,6 +58,48 @@ fi echo "git: $(git --version)" echo "" +# Check Node.js (required for Tauri app frontend) +echo "--- Checking Node.js ---" +if ! command -v node &> /dev/null; then + echo "ERROR: Node.js not found." + echo "Install with: brew install node" + echo "Or via nvm: https://github.com/nvm-sh/nvm" + exit 1 +fi +echo "node: $(node --version)" +echo "" + +# Enable pnpm via Corepack +echo "--- Enabling pnpm (Corepack) ---" +if ! command -v corepack &> /dev/null; then + echo "ERROR: Corepack not found. Requires Node.js 16.10+." + echo "Reinstall Node or run: npm install -g corepack" + exit 1 +fi +corepack enable pnpm +echo "pnpm: $(corepack pnpm --version)" +echo "" + +# Install Tauri app frontend dependencies +echo "--- Installing Tauri app frontend dependencies ---" +UI_DIR="$PROJECT_DIR/crates/git-same-app/ui" +if ! corepack pnpm --dir "$UI_DIR" install --frozen-lockfile; then + echo "WARNING: --frozen-lockfile failed, retrying without it." + corepack pnpm --dir "$UI_DIR" install +fi +echo "" + +# Sanity-check Tauri CLI +echo "--- Checking Tauri CLI ---" +TAURI_CLI="$UI_DIR/node_modules/.bin/tauri" +if [ ! -x "$TAURI_CLI" ] || ! "$TAURI_CLI" --version &> /dev/null; then + echo "ERROR: Tauri CLI not runnable at $TAURI_CLI" + echo "Re-run: corepack pnpm --dir $UI_DIR install" + exit 1 +fi +echo "tauri: $("$TAURI_CLI" --version)" +echo "" + # Clean build artifacts and update dependencies echo "--- Cleaning Build Cache ---" cargo clean @@ -72,6 +114,7 @@ echo "========================================" echo "" echo "Next steps:" echo " 1. Run: ./toolkit/conductor/run.sh" +echo " (installs the CLI/TUI and launches the Tauri app in dev mode)" echo " 2. Or manually install: cargo install --path crates/git-same-cli --force" echo " (then refresh aliases via ./toolkit/conductor/run.sh)" echo "" From 9aae4f2916a55f3c06866b62ab48acc5de2d86e1 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 08:19:15 +0200 Subject: [PATCH 40/89] Split Tauri app UI into routes and shared store for maintainability Extract App.svelte from 565 lines to a 61-line shell composing Sidebar, Topbar, Banner, and a Router. Move repo table, stats strip, and settings panel into route + lib components reading from a shared writable store. Add svelte-spa-router for hash-based routing between Dashboard and Settings (workspaces stay a sidebar filter, not a route). Document in CLAUDE.md that macos/GitSameSwiftApp/ is intentionally kept alongside the Tauri host as a fallback, overriding the migration plan's earlier "delete in Phase C" instruction. --- crates/git-same-app/ui/package.json | 3 +- crates/git-same-app/ui/pnpm-lock.yaml | 16 + crates/git-same-app/ui/src/App.svelte | 539 +----------------- crates/git-same-app/ui/src/lib/Banner.svelte | 50 ++ .../git-same-app/ui/src/lib/RepoTable.svelte | 136 +++++ crates/git-same-app/ui/src/lib/Sidebar.svelte | 119 ++++ .../git-same-app/ui/src/lib/StatStrip.svelte | 47 ++ crates/git-same-app/ui/src/lib/Topbar.svelte | 111 ++++ crates/git-same-app/ui/src/lib/utils.ts | 45 ++ .../ui/src/routes/Dashboard.svelte | 7 + .../ui/src/routes/Settings.svelte | 58 ++ crates/git-same-app/ui/src/routes/router.ts | 9 + crates/git-same-app/ui/src/stores/status.ts | 59 ++ crates/git-same-app/ui/src/styles/tokens.css | 10 + 14 files changed, 687 insertions(+), 522 deletions(-) create mode 100644 crates/git-same-app/ui/src/lib/Banner.svelte create mode 100644 crates/git-same-app/ui/src/lib/RepoTable.svelte create mode 100644 crates/git-same-app/ui/src/lib/Sidebar.svelte create mode 100644 crates/git-same-app/ui/src/lib/StatStrip.svelte create mode 100644 crates/git-same-app/ui/src/lib/Topbar.svelte create mode 100644 crates/git-same-app/ui/src/lib/utils.ts create mode 100644 crates/git-same-app/ui/src/routes/Dashboard.svelte create mode 100644 crates/git-same-app/ui/src/routes/Settings.svelte create mode 100644 crates/git-same-app/ui/src/routes/router.ts create mode 100644 crates/git-same-app/ui/src/stores/status.ts diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json index e996f9a..8f7ca53 100644 --- a/crates/git-same-app/ui/package.json +++ b/crates/git-same-app/ui/package.json @@ -12,7 +12,8 @@ "dependencies": { "@tauri-apps/api": "^2.9.0", "lucide-svelte": "^0.468.0", - "svelte": "^5.0.0" + "svelte": "^5.0.0", + "svelte-spa-router": "^4.0.1" }, "devDependencies": { "@tauri-apps/cli": "^2.9.0", diff --git a/crates/git-same-app/ui/pnpm-lock.yaml b/crates/git-same-app/ui/pnpm-lock.yaml index 5f18e27..1837827 100644 --- a/crates/git-same-app/ui/pnpm-lock.yaml +++ b/crates/git-same-app/ui/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: svelte: specifier: ^5.0.0 version: 5.55.5 + svelte-spa-router: + specifier: ^4.0.1 + version: 4.0.2 devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^5.0.0 @@ -563,6 +566,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + regexparam@2.0.2: + resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==} + engines: {node: '>=8'} + rollup@4.60.3: resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -584,6 +591,9 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' + svelte-spa-router@4.0.2: + resolution: {integrity: sha512-T1WYYk+ymwCr5m5U+n91k4dRAT6cw5HgmoPaI/TpKgAmuugymFoSBlfzkcKIK83QH4H8gUMn4tdQ0B9enFBM6g==} + svelte@5.55.5: resolution: {integrity: sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==} engines: {node: '>=18'} @@ -997,6 +1007,8 @@ snapshots: readdirp@4.1.2: {} + regexparam@2.0.2: {} + rollup@4.60.3: dependencies: '@types/estree': 1.0.8 @@ -1046,6 +1058,10 @@ snapshots: transitivePeerDependencies: - picomatch + svelte-spa-router@4.0.2: + dependencies: + regexparam: 2.0.2 + svelte@5.55.5: dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/crates/git-same-app/ui/src/App.svelte b/crates/git-same-app/ui/src/App.svelte index 527f630..52a50be 100644 --- a/crates/git-same-app/ui/src/App.svelte +++ b/crates/git-same-app/ui/src/App.svelte @@ -1,107 +1,30 @@ @@ -109,122 +32,11 @@
- - +
-
-
-

{activeView === 'settings' ? 'Settings' : currentWorkspace?.id ?? 'Dashboard'}

-

{currentWorkspace?.root ?? snapshot?.status_path ?? 'No workspace selected'}

-
-
- - {#if activeView === 'dashboard'} - - {/if} -
-
- - {#if error} - - {:else if snapshot?.stale} - - {/if} - - {#if activeView === 'settings'} -
-

Finder Badges

-
-
-
Status file
-
{snapshot?.status_path ?? 'Unavailable'}
-
-
-
Last update
-
{relativeTime(snapshot?.updated_at)}
-
-
-
Daemon PID
-
{snapshot?.status?.daemon_pid ?? 'Unavailable'}
-
-
-
- {:else} -
-
{counts.total}Total
-
{counts.green}Synced
-
{counts.blue}Local config
-
{counts.orange}Branches
-
{counts.red}Local work
-
- -
-
- Repository - State - Branch - Changes - Remote -
- {#if workspaceRepos.length === 0} -
No status rows
- {:else} - {#each workspaceRepos.slice(0, 200) as repo} -
-
- {repoName(repo.path)} - {repo.org ?? repo.workspace ?? repo.path} -
- {badgeLabel(repo.badge)} - {repo.current_branch} - {repo.staged_count + repo.unstaged_count + repo.untracked_count} - {repo.ahead} ahead / {repo.behind} behind -
- {/each} - {/if} -
- {/if} + + +
@@ -236,329 +48,14 @@ color: var(--text); } - .sidebar { - border-right: 1px solid var(--line); - background: var(--panel); - padding: 18px 14px; - } - - .brand, - .nav button, - .actions, - .banner, - .primary, - .workspace-list button { - display: flex; - align-items: center; - } - - .brand { - gap: 10px; - height: 40px; - font-size: 18px; - font-weight: 700; - } - - .nav { - display: grid; - gap: 6px; - margin: 24px 0; - } - - .nav button, - .workspace-list button { - width: 100%; - border: 0; - border-radius: 8px; - background: transparent; - color: var(--text); - cursor: pointer; - text-align: left; - } - - .nav button { - gap: 9px; - height: 38px; - padding: 0 10px; - } - - .nav button.active, - .workspace-list button.active { - background: var(--panel-alt); - } - - .workspace-list { - display: grid; - gap: 8px; - } - - .workspace-list button { - display: grid; - gap: 3px; - min-height: 58px; - padding: 10px; - } - - .workspace-list span, - .workspace-list small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .workspace-list small, - .topbar p, - .repo-row small { - color: var(--muted); - } - .content { min-width: 0; padding: 22px; } - .topbar { - display: flex; - justify-content: space-between; - gap: 18px; - align-items: center; - margin-bottom: 18px; - } - - h1, - h2, - p { - margin: 0; - } - - h1 { - font-size: 24px; - line-height: 1.2; - } - - .topbar p { - margin-top: 5px; - overflow-wrap: anywhere; - } - - .actions { - gap: 8px; - } - - .icon-button, - .primary { - border: 1px solid var(--line); - border-radius: 8px; - cursor: pointer; - } - - .icon-button { - width: 38px; - height: 38px; - display: inline-grid; - place-items: center; - background: var(--panel); - color: var(--text); - } - - .primary { - gap: 8px; - height: 38px; - padding: 0 13px; - background: var(--accent); - color: white; - font-weight: 700; - } - - .primary:disabled { - cursor: not-allowed; - opacity: 0.65; - } - - .banner { - gap: 10px; - min-height: 42px; - margin-bottom: 16px; - padding: 10px 12px; - border-radius: 8px; - border: 1px solid var(--line); - background: var(--panel); - overflow-wrap: anywhere; - } - - .banner.warning { - color: var(--warning); - } - - .banner.error { - color: var(--danger); - } - - code { - color: var(--text); - background: var(--panel-alt); - padding: 3px 6px; - border-radius: 6px; - } - - .stats { - display: grid; - grid-template-columns: repeat(5, minmax(120px, 1fr)); - gap: 12px; - margin-bottom: 16px; - } - - .stats div, - .repo-table, - .settings-panel { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: var(--shadow); - } - - .stats div { - min-height: 78px; - padding: 14px; - } - - .stats strong { - display: block; - font-size: 24px; - } - - .stats span { - color: var(--muted); - } - - .repo-table { - overflow: hidden; - } - - .table-head, - .repo-row { - display: grid; - grid-template-columns: minmax(180px, 1.7fr) 120px 130px 90px 140px; - gap: 12px; - align-items: center; - min-height: 48px; - padding: 0 14px; - } - - .table-head { - background: var(--panel-alt); - color: var(--muted); - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - } - - .repo-row { - border-top: 1px solid var(--line); - } - - .repo-row strong, - .repo-row small { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .badge { - width: fit-content; - min-width: 84px; - border-radius: 999px; - padding: 4px 8px; - font-size: 12px; - font-weight: 700; - text-align: center; - } - - .green { - background: color-mix(in srgb, var(--ok) 16%, transparent); - color: var(--ok); - } - - .blue { - background: color-mix(in srgb, var(--blue) 16%, transparent); - color: var(--blue); - } - - .orange { - background: color-mix(in srgb, var(--warning) 18%, transparent); - color: var(--warning); - } - - .red { - background: color-mix(in srgb, var(--danger) 16%, transparent); - color: var(--danger); - } - - .gray { - background: var(--panel-alt); - color: var(--muted); - } - - .empty, - .settings-panel { - padding: 18px; - } - - .settings-panel dl { - display: grid; - gap: 12px; - margin: 16px 0 0; - } - - .settings-panel div { - display: grid; - gap: 4px; - } - - .settings-panel dt { - color: var(--muted); - font-size: 12px; - text-transform: uppercase; - } - - .settings-panel dd { - margin: 0; - overflow-wrap: anywhere; - } - - .spinning { - animation: spin 1s linear infinite; - } - - @keyframes spin { - to { - transform: rotate(360deg); - } - } - @media (max-width: 860px) { .shell { grid-template-columns: 1fr; } - - .sidebar { - border-right: 0; - border-bottom: 1px solid var(--line); - } - - .stats { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .table-head { - display: none; - } - - .repo-row { - grid-template-columns: 1fr; - gap: 6px; - padding: 12px 14px; - } } diff --git a/crates/git-same-app/ui/src/lib/Banner.svelte b/crates/git-same-app/ui/src/lib/Banner.svelte new file mode 100644 index 0000000..1d1ce7d --- /dev/null +++ b/crates/git-same-app/ui/src/lib/Banner.svelte @@ -0,0 +1,50 @@ + + +{#if showError} + +{:else if showStale} + +{/if} + + diff --git a/crates/git-same-app/ui/src/lib/RepoTable.svelte b/crates/git-same-app/ui/src/lib/RepoTable.svelte new file mode 100644 index 0000000..13edb9e --- /dev/null +++ b/crates/git-same-app/ui/src/lib/RepoTable.svelte @@ -0,0 +1,136 @@ + + +
+
+ Repository + State + Branch + Changes + Remote +
+ {#if workspaceRepos.length === 0} +
No status rows
+ {:else} + {#each workspaceRepos.slice(0, 200) as repo} +
+
+ {repoName(repo.path)} + {repo.org ?? repo.workspace ?? repo.path} +
+ {badgeLabel(repo.badge)} + {repo.current_branch} + {repo.staged_count + repo.unstaged_count + repo.untracked_count} + {repo.ahead} ahead / {repo.behind} behind +
+ {/each} + {/if} +
+ + diff --git a/crates/git-same-app/ui/src/lib/Sidebar.svelte b/crates/git-same-app/ui/src/lib/Sidebar.svelte new file mode 100644 index 0000000..88a9b2f --- /dev/null +++ b/crates/git-same-app/ui/src/lib/Sidebar.svelte @@ -0,0 +1,119 @@ + + + + + diff --git a/crates/git-same-app/ui/src/lib/StatStrip.svelte b/crates/git-same-app/ui/src/lib/StatStrip.svelte new file mode 100644 index 0000000..5d2e9a8 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/StatStrip.svelte @@ -0,0 +1,47 @@ + + +
+
{counts.total}Total
+
{counts.green}Synced
+
{counts.blue}Local config
+
{counts.orange}Branches
+
{counts.red}Local work
+
+ + diff --git a/crates/git-same-app/ui/src/lib/Topbar.svelte b/crates/git-same-app/ui/src/lib/Topbar.svelte new file mode 100644 index 0000000..fba3d5d --- /dev/null +++ b/crates/git-same-app/ui/src/lib/Topbar.svelte @@ -0,0 +1,111 @@ + + +
+
+

{title}

+

{subtitle}

+
+
+ + {#if !isSettings} + + {/if} +
+
+ + diff --git a/crates/git-same-app/ui/src/lib/utils.ts b/crates/git-same-app/ui/src/lib/utils.ts new file mode 100644 index 0000000..90c16d2 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/utils.ts @@ -0,0 +1,45 @@ +import type { Badge, FinderRepoStatus } from './types'; + +export function summarize(items: FinderRepoStatus[]) { + return items.reduce( + (acc, repo) => { + acc.total += 1; + acc[repo.badge] += 1; + return acc; + }, + { total: 0, green: 0, blue: 0, orange: 0, red: 0, gray: 0 } as Record< + Badge | 'total', + number + >, + ); +} + +export function badgeLabel(badge: Badge): string { + return { + green: 'Synced', + blue: 'Local config', + orange: 'Branches', + red: 'Local work', + gray: 'Pending', + }[badge]; +} + +export function repoName(path: string): string { + return path.split('/').filter(Boolean).at(-1) ?? path; +} + +export function relativeTime(value: string | null | undefined): string { + if (!value) return 'Never'; + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) return value; + const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${hours}h ago`; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(timestamp); +} diff --git a/crates/git-same-app/ui/src/routes/Dashboard.svelte b/crates/git-same-app/ui/src/routes/Dashboard.svelte new file mode 100644 index 0000000..c7d4fa1 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/Dashboard.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/crates/git-same-app/ui/src/routes/Settings.svelte b/crates/git-same-app/ui/src/routes/Settings.svelte new file mode 100644 index 0000000..8cf4426 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/Settings.svelte @@ -0,0 +1,58 @@ + + +
+

Finder Badges

+
+
+
Status file
+
{$snapshot?.status_path ?? 'Unavailable'}
+
+
+
Last update
+
{relativeTime($snapshot?.updated_at)}
+
+
+
Daemon PID
+
{$snapshot?.status?.daemon_pid ?? 'Unavailable'}
+
+
+
+ + diff --git a/crates/git-same-app/ui/src/routes/router.ts b/crates/git-same-app/ui/src/routes/router.ts new file mode 100644 index 0000000..55a2b7c --- /dev/null +++ b/crates/git-same-app/ui/src/routes/router.ts @@ -0,0 +1,9 @@ +import type { RouteDefinition } from 'svelte-spa-router'; +import Dashboard from './Dashboard.svelte'; +import Settings from './Settings.svelte'; + +export const routes: RouteDefinition = { + '/': Dashboard, + '/settings': Settings, + '*': Dashboard, +}; diff --git a/crates/git-same-app/ui/src/stores/status.ts b/crates/git-same-app/ui/src/stores/status.ts new file mode 100644 index 0000000..4fad9f2 --- /dev/null +++ b/crates/git-same-app/ui/src/stores/status.ts @@ -0,0 +1,59 @@ +import { derived, get, writable } from 'svelte/store'; +import { + listWorkspaces, + onStatusUpdated, + readStatus, + startSync, +} from '../lib/tauri'; +import type { StatusSnapshot, WorkspaceSummary } from '../lib/types'; + +export const snapshot = writable(null); +export const workspaces = writable([]); +export const selectedWorkspaceId = writable(''); +export const loading = writable(true); +export const syncingId = writable(''); +export const errorMessage = writable(''); + +export const currentWorkspace = derived( + [workspaces, selectedWorkspaceId], + ([$workspaces, $selectedWorkspaceId]) => + $workspaces.find((workspace) => workspace.id === $selectedWorkspaceId) ?? + $workspaces[0], +); + +export async function refresh(): Promise { + errorMessage.set(''); + const [workspaceList, status] = await Promise.all([ + listWorkspaces(), + readStatus(), + ]); + workspaces.set(workspaceList); + snapshot.set(status); + if (!get(selectedWorkspaceId) && workspaceList.length > 0) { + const defaultId = + workspaceList.find((workspace) => workspace.default)?.id ?? + workspaceList[0].id; + selectedWorkspaceId.set(defaultId); + } +} + +export async function startSyncCurrent(): Promise { + const workspace = get(currentWorkspace); + if (!workspace) return; + syncingId.set(workspace.id); + errorMessage.set(''); + try { + await startSync(workspace.id); + await refresh(); + } catch (err) { + errorMessage.set(String(err)); + } finally { + syncingId.set(''); + } +} + +export async function subscribePush(): Promise<() => void> { + return onStatusUpdated((next) => { + snapshot.set(next); + }); +} diff --git a/crates/git-same-app/ui/src/styles/tokens.css b/crates/git-same-app/ui/src/styles/tokens.css index cbd6a7e..42d1f81 100644 --- a/crates/git-same-app/ui/src/styles/tokens.css +++ b/crates/git-same-app/ui/src/styles/tokens.css @@ -55,3 +55,13 @@ body { button { font: inherit; } + +.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} From 7d3727ae16fd6351fa4ef3e0270c7b5dee7ac8db Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 13:45:46 +0200 Subject: [PATCH 41/89] Fix Tauri dev launch by cd-ing into app dir instead of using cargo-only flag The Conductor run.sh was invoking the npm @tauri-apps/cli with --manifest-path, but that flag only exists on the cargo-tauri binary, so the script failed with "unexpected argument '--manifest-path' found" right after install. The npm CLI auto-detects tauri.conf.json from cwd, so cd into crates/git-same-app/ before exec'ing tauri dev. This matches the pattern already used in S1-Test-CI.yml. --- toolkit/conductor/run.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/toolkit/conductor/run.sh b/toolkit/conductor/run.sh index 517675b..786afd3 100755 --- a/toolkit/conductor/run.sh +++ b/toolkit/conductor/run.sh @@ -142,5 +142,5 @@ echo "========================================" echo " Launching Tauri app (dev mode)" echo "========================================" echo "" -cd "$PROJECT_DIR" -exec "$TAURI_CLI" dev --manifest-path crates/git-same-app/Cargo.toml +cd "$PROJECT_DIR/crates/git-same-app" +exec "$TAURI_CLI" dev From 89d8c52cbd7a859541a762434a40030d9b2a6074 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 15:07:49 +0200 Subject: [PATCH 42/89] Wire D-App + D-Finder to ship together in 3.1.0 with entitlement parity guard Default S5's include_finder_extension to true so a tag push to 3.1.0 ships the bundled .app with the FinderSync extension embedded in one DMG, instead of staging D-App and D-Finder as separate releases. Add a check-entitlements-parity.sh guard that diffs the application-groups ids between the Tauri host and the Badges extension entitlements. A drift there silently splits the runtime app-group container and breaks badge rendering with no obvious error, so the check runs in S1's macOS Tauri job on every PR and again in S5 before the bundle is built. Cask postflight now runs pluginkit -e ignore on the stale pre-rename id com.zaai.git-same.GitSameBadge.FinderSync to clear leftover registrations from dev-built seed extensions; no-op for users without that history. --- .github/workflows/S1-Test-CI.yml | 3 + .github/workflows/S5-Build-MacOS-App.yml | 10 +++- toolkit/homebrew/cask.rb.tmpl | 8 +++ .../macos/check-entitlements-parity.sh | 55 +++++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100755 toolkit/packaging/macos/check-entitlements-parity.sh diff --git a/.github/workflows/S1-Test-CI.yml b/.github/workflows/S1-Test-CI.yml index 05b8d84..8697a8a 100644 --- a/.github/workflows/S1-Test-CI.yml +++ b/.github/workflows/S1-Test-CI.yml @@ -105,6 +105,9 @@ jobs: - name: Enable pnpm run: corepack enable pnpm + - name: Verify entitlements parity + run: bash toolkit/packaging/macos/check-entitlements-parity.sh + - name: Install frontend dependencies run: pnpm --dir crates/git-same-app/ui install --frozen-lockfile diff --git a/.github/workflows/S5-Build-MacOS-App.yml b/.github/workflows/S5-Build-MacOS-App.yml index 91ec60d..aecdee7 100644 --- a/.github/workflows/S5-Build-MacOS-App.yml +++ b/.github/workflows/S5-Build-MacOS-App.yml @@ -11,7 +11,7 @@ on: description: "Embed the FinderSync extension" required: true type: boolean - default: false + default: true push: tags: - "*.*.*" @@ -71,13 +71,19 @@ jobs: - name: Enable pnpm run: corepack enable pnpm + - name: Verify entitlements parity + run: bash toolkit/packaging/macos/check-entitlements-parity.sh + - name: Build signed app DMG env: VERSION: ${{ steps.version.outputs.version }} ARCH: ${{ matrix.arch }} WORKSPACE_ROOT: ${{ github.workspace }} OUTPUT_DIR: ${{ github.workspace }}/dist/macos - INCLUDE_FINDER_EXTENSION: ${{ inputs.include_finder_extension && '1' || '0' }} + # Default-on: tag pushes (where `inputs.include_finder_extension` is + # null) ship the full bundle. Only an explicit `false` from + # workflow_dispatch suppresses the appex. + INCLUDE_FINDER_EXTENSION: ${{ inputs.include_finder_extension == false && '0' || '1' }} APPLE_DEVELOPER_CERTIFICATE_P12: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12 }} APPLE_DEVELOPER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} diff --git a/toolkit/homebrew/cask.rb.tmpl b/toolkit/homebrew/cask.rb.tmpl index 267ca3b..cdc1a4a 100644 --- a/toolkit/homebrew/cask.rb.tmpl +++ b/toolkit/homebrew/cask.rb.tmpl @@ -40,6 +40,14 @@ cask "git-same" do File.write(plist_dst, rendered) system_command "/bin/launchctl", args: ["unload", plist_dst], sudo: false, must_succeed: false system_command "/bin/launchctl", args: ["load", plist_dst], sudo: false, must_succeed: false + + # Clear stale FinderSync registration from pre-rename builds (id was + # `com.zaai.git-same.GitSameBadge.FinderSync`; renamed to + # `com.zaai.git-same.Badges` in 3.1.0). Best-effort: ignored if the id + # is not present in pluginkit's cache. + system_command "/usr/bin/pluginkit", + args: ["-e", "ignore", "-i", "com.zaai.git-same.GitSameBadge.FinderSync"], + sudo: false, must_succeed: false end uninstall launchctl: "com.zaai.git-same.daemon", diff --git a/toolkit/packaging/macos/check-entitlements-parity.sh b/toolkit/packaging/macos/check-entitlements-parity.sh new file mode 100755 index 0000000..6968c68 --- /dev/null +++ b/toolkit/packaging/macos/check-entitlements-parity.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Verify that the Tauri host and the FinderSync extension declare matching +# `com.apple.security.application-groups` entries. +# +# A typo here silently splits the runtime container: the daemon writes to +# one group and the extension reads from another, and badges stop rendering +# without an obvious error. CI must catch this before signing. +# +# macOS-only (uses /usr/bin/plutil). Run from S5 before the bundle build, +# and from S1's macOS Tauri build job so PRs catch drift early. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +HOST="$ROOT/crates/git-same-app/entitlements.plist" +EXT="$ROOT/macos/GitSameBadges/GitSameBadges.entitlements" + +for f in "$HOST" "$EXT"; do + if [ ! -r "$f" ]; then + echo "ERROR: entitlements file not readable: $f" >&2 + exit 1 + fi +done + +extract_groups() { + # PlistBuddy treats `:` as path separators (not `.`), so dotted keys like + # `com.apple.security.application-groups` work as a single segment. + # `-x` emits XML; we pull the inner elements and sort them so + # the comparison is order-insensitive. + /usr/libexec/PlistBuddy -x \ + -c "Print :com.apple.security.application-groups" "$1" \ + | grep -oE '[^<]*' \ + | sed 's///' \ + | sort +} + +HOST_GROUPS="$(extract_groups "$HOST")" +EXT_GROUPS="$(extract_groups "$EXT")" + +if [ "$HOST_GROUPS" != "$EXT_GROUPS" ]; then + echo "ERROR: application-groups mismatch between host and extension." >&2 + echo " host ($HOST):" >&2 + echo " $HOST_GROUPS" >&2 + echo " ext ($EXT):" >&2 + echo " $EXT_GROUPS" >&2 + echo "" >&2 + echo "These lists must be identical. A mismatch silently splits the" >&2 + echo "runtime app-group container and breaks Finder badge rendering." >&2 + exit 1 +fi + +echo "OK: application-groups match across host and extension" +echo " $HOST_GROUPS" From 994e499ca0160ed1f75b71343d479451c74323ec Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 15:19:02 +0200 Subject: [PATCH 43/89] Capitalize Git-Same brand string in Tauri app for consistent product naming --- crates/git-same-app/src/main.rs | 2 +- crates/git-same-app/tauri.conf.json | 4 ++-- crates/git-same-app/ui/index.html | 2 +- crates/git-same-app/ui/src/App.svelte | 2 +- crates/git-same-app/ui/src/lib/Sidebar.svelte | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs index 3eb5586..a607e17 100644 --- a/crates/git-same-app/src/main.rs +++ b/crates/git-same-app/src/main.rs @@ -15,5 +15,5 @@ fn main() { Ok(()) }) .run(tauri::generate_context!()) - .expect("error while running git-Same"); + .expect("error while running Git-Same"); } diff --git a/crates/git-same-app/tauri.conf.json b/crates/git-same-app/tauri.conf.json index 7ac43a4..ca4a38a 100644 --- a/crates/git-same-app/tauri.conf.json +++ b/crates/git-same-app/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "git-Same", + "productName": "Git-Same", "version": "3.1.0", "identifier": "com.zaai.git-same", "build": { @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "git-Same", + "title": "Git-Same", "width": 1100, "height": 720, "minWidth": 820, diff --git a/crates/git-same-app/ui/index.html b/crates/git-same-app/ui/index.html index 62e6b31..eaa1773 100644 --- a/crates/git-same-app/ui/index.html +++ b/crates/git-same-app/ui/index.html @@ -3,7 +3,7 @@ - git-Same + Git-Same
diff --git a/crates/git-same-app/ui/src/App.svelte b/crates/git-same-app/ui/src/App.svelte index 52a50be..f998763 100644 --- a/crates/git-same-app/ui/src/App.svelte +++ b/crates/git-same-app/ui/src/App.svelte @@ -28,7 +28,7 @@ - git-Same + Git-Same
diff --git a/crates/git-same-app/ui/src/lib/Sidebar.svelte b/crates/git-same-app/ui/src/lib/Sidebar.svelte index 88a9b2f..a371af5 100644 --- a/crates/git-same-app/ui/src/lib/Sidebar.svelte +++ b/crates/git-same-app/ui/src/lib/Sidebar.svelte @@ -10,7 +10,7 @@ diff --git a/crates/git-same-app/ui/src/lib/StatusBanner.svelte b/crates/git-same-app/ui/src/lib/StatusBanner.svelte new file mode 100644 index 0000000..06ace64 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/StatusBanner.svelte @@ -0,0 +1,223 @@ + + +{#if showSuccess} + +{:else if showError} + +{:else if showProgress} + +{:else if showStale} + +{:else if showAllowExt} + +{:else if showFda} + +{/if} + + diff --git a/crates/git-same-app/ui/src/lib/TitleBar.svelte b/crates/git-same-app/ui/src/lib/TitleBar.svelte new file mode 100644 index 0000000..32779ee --- /dev/null +++ b/crates/git-same-app/ui/src/lib/TitleBar.svelte @@ -0,0 +1,186 @@ + + +
+
+

{title}

+

{subtitle}

+
+
+ + {#if showRecheck} + + {/if} + {#if showSync} + + {/if} +
+
+ + diff --git a/crates/git-same-app/ui/src/lib/tauri.ts b/crates/git-same-app/ui/src/lib/tauri.ts index 197361b..9e3ca70 100644 --- a/crates/git-same-app/ui/src/lib/tauri.ts +++ b/crates/git-same-app/ui/src/lib/tauri.ts @@ -1,9 +1,17 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; +import { open } from '@tauri-apps/plugin-dialog'; import type { + AppConfigDto, + AppConfigInput, ExtensionStatus, + ProviderDiscoveryDto, + RequirementCheckDto, StatusSnapshot, SyncProgressPayload, + WorkspaceDetailDto, + WorkspaceInput, + WorkspaceProviderDto, WorkspaceSummary, } from './types'; @@ -11,6 +19,46 @@ export function listWorkspaces(): Promise { return invoke('list_workspaces'); } +export function readAppConfig(): Promise { + return invoke('read_app_config'); +} + +export function ensureConfig(): Promise { + return invoke('ensure_config'); +} + +export function saveAppConfig(input: AppConfigInput): Promise { + return invoke('save_app_config', { input }); +} + +export function readWorkspace(workspaceId: string): Promise { + return invoke('read_workspace', { workspaceId }); +} + +export function saveWorkspace(input: WorkspaceInput): Promise { + return invoke('save_workspace', { input }); +} + +export function deleteWorkspace(workspaceId: string): Promise { + return invoke('delete_workspace', { workspaceId }); +} + +export function setDefaultWorkspace( + workspaceId: string | null, +): Promise { + return invoke('set_default_workspace', { workspaceId }); +} + +export function checkRequirements(): Promise { + return invoke('check_requirements'); +} + +export function discoverProviderOrgs( + provider: WorkspaceProviderDto, +): Promise { + return invoke('discover_provider_orgs', { provider }); +} + export function readStatus(): Promise { return invoke('read_status'); } @@ -27,6 +75,15 @@ export function openUrl(url: string): Promise { return invoke('open_url', { url }); } +export async function chooseFolder(defaultPath?: string): Promise { + const selected = await open({ + directory: true, + multiple: false, + defaultPath, + }); + return typeof selected === 'string' ? selected : null; +} + export function onStatusUpdated(callback: (snapshot: StatusSnapshot) => void) { return listen('status-updated', (event) => callback(event.payload)); } diff --git a/crates/git-same-app/ui/src/lib/types.ts b/crates/git-same-app/ui/src/lib/types.ts index 7d66627..4c5010c 100644 --- a/crates/git-same-app/ui/src/lib/types.ts +++ b/crates/git-same-app/ui/src/lib/types.ts @@ -1,7 +1,10 @@ export type Badge = 'green' | 'blue' | 'orange' | 'red' | 'gray'; +export type SyncMode = 'fetch' | 'pull'; + export interface WorkspaceSummary { id: string; + name: string; root: string; provider: string; org_count: number; @@ -9,12 +12,137 @@ export interface WorkspaceSummary { default: boolean; } +export interface CloneOptionsDto { + depth: number; + branch: string; + recurse_submodules: boolean; +} + +export interface FilterOptionsDto { + include_archived: boolean; + include_forks: boolean; + orgs: string[]; + exclude_repos: string[]; +} + +export interface FinderConfigDto { + scan_roots: string[]; + max_depth: number; + exclude_dirs: string[]; + show_ambient: boolean; +} + +export interface AppConfigDto { + config_path: string; + exists: boolean; + structure: string; + concurrency: number; + sync_mode: SyncMode; + default_workspace: string | null; + refresh_interval: number; + clone: CloneOptionsDto; + filters: FilterOptionsDto; + workspaces: string[]; + finder: FinderConfigDto; +} + +export type AppConfigInput = Omit; + +export interface WorkspaceProviderDto { + kind: string; + label: string; + api_url: string | null; + prefer_ssh: boolean; +} + +export interface WorkspaceDetailDto { + id: string; + name: string; + root: string; + config_path: string; + provider: WorkspaceProviderDto; + username: string; + orgs: string[]; + include_repos: string[]; + exclude_repos: string[]; + structure: string | null; + sync_mode: SyncMode | null; + clone_options: CloneOptionsDto | null; + filters: FilterOptionsDto; + concurrency: number | null; + refresh_interval: number | null; + last_synced: string | null; + default: boolean; +} + +export interface WorkspaceInput { + id: string | null; + root: string; + provider: WorkspaceProviderDto; + username: string; + orgs: string[]; + include_repos: string[]; + exclude_repos: string[]; + structure: string | null; + sync_mode: SyncMode | null; + clone_options: CloneOptionsDto | null; + filters: FilterOptionsDto; + concurrency: number | null; + refresh_interval: number | null; + default: boolean; +} + +export interface RequirementCheckDto { + name: string; + passed: boolean; + message: string; + suggestion: string | null; + critical: boolean; +} + +export interface ProviderOrgDto { + name: string; + repo_count: number; + selected: boolean; +} + +export interface ProviderDiscoveryDto { + username: string | null; + orgs: ProviderOrgDto[]; +} + export interface FinderWorkspaceInfo { name: string; root: string; orgs: string[]; } +export interface FinderBranchInfo { + name: string; + upstream?: string; + ahead: number; + behind: number; + synced: boolean; +} + +export interface FinderRemoteInfo { + name: string; + url: string; +} + +export interface FinderWorktreeInfo { + path: string; + branch?: string; + synced: boolean; +} + +export interface OrgFolderInfo { + path: string; + org: string; + workspace: string; + owner_type: 'user' | 'organization' | 'unknown'; +} + export interface FinderRepoStatus { path: string; workspace?: string; @@ -31,7 +159,10 @@ export interface FinderRepoStatus { stash_count: number; has_important_ignored_files: boolean; important_ignored_files?: string[]; + branches: FinderBranchInfo[]; all_branches_synced: boolean; + remotes: FinderRemoteInfo[]; + worktrees: FinderWorktreeInfo[]; all_worktrees_synced: boolean; read_error?: string; } @@ -43,6 +174,7 @@ export interface FinderStatus { workspaces: FinderWorkspaceInfo[]; custom_folders?: string[]; repos: FinderRepoStatus[]; + org_folders?: OrgFolderInfo[]; monitored_roots?: string[]; } diff --git a/crates/git-same-app/ui/src/lib/utils.ts b/crates/git-same-app/ui/src/lib/utils.ts index 90c16d2..0a76345 100644 --- a/crates/git-same-app/ui/src/lib/utils.ts +++ b/crates/git-same-app/ui/src/lib/utils.ts @@ -28,6 +28,39 @@ export function repoName(path: string): string { return path.split('/').filter(Boolean).at(-1) ?? path; } +export function folderName(path: string): string { + return repoName(path) || path; +} + +export function parentPath(path: string): string { + const parts = path.split('/').filter(Boolean); + if (parts.length <= 1) return path; + return `${path.startsWith('/') ? '/' : ''}${parts.slice(0, -1).join('/')}`; +} + +export function linesToList(value: string): string[] { + return value + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); +} + +export function listToLines(value: string[] | null | undefined): string { + return (value ?? []).join('\n'); +} + +export function formatCount(value: number, singular: string, plural = `${singular}s`) { + return `${value} ${value === 1 ? singular : plural}`; +} + +export function repoChangeCount(repo: FinderRepoStatus): number { + return repo.staged_count + repo.unstaged_count + repo.untracked_count; +} + +export function isHighRiskRepo(repo: FinderRepoStatus): boolean { + return repo.badge === 'red' || Boolean(repo.read_error); +} + export function relativeTime(value: string | null | undefined): string { if (!value) return 'Never'; const timestamp = Date.parse(value); diff --git a/crates/git-same-app/ui/src/routes/BadgeBrowser.svelte b/crates/git-same-app/ui/src/routes/BadgeBrowser.svelte new file mode 100644 index 0000000..58686e1 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/BadgeBrowser.svelte @@ -0,0 +1,440 @@ + + +
+
+ + + + +
+ +
+ + + + + +
+ +
+
+
+ Repository + Badge + Branch + Changes + Remote +
+ {#if filtered.length === 0} + + {:else} + {#each filtered as repo} + + {/each} + {/if} +
+ + {#if selectedRepo} + + {/if} +
+
+ + diff --git a/crates/git-same-app/ui/src/routes/Dashboard.svelte b/crates/git-same-app/ui/src/routes/Dashboard.svelte index c7d4fa1..b89ea7f 100644 --- a/crates/git-same-app/ui/src/routes/Dashboard.svelte +++ b/crates/git-same-app/ui/src/routes/Dashboard.svelte @@ -1,7 +1,330 @@ - - +
+
+
+ + {#if monitorOk}{:else}{/if} + +
+ {monitorOk ? 'Monitor running' : 'Monitor needs attention'} +

Last scan {relativeTime($snapshot?.updated_at)}

+
+
+
+ + {#if extensionOk}{:else}{/if} + +
+ {extensionOk ? 'Finder extension enabled' : 'Finder extension not ready'} +

{$extensionStatus?.installed ? 'Installed' : 'Not installed'} · {$extensionStatus?.enabled ? 'Enabled' : 'Disabled'}

+
+
+
+ {$workspaces.length} + {formatCount($workspaces.length, 'workspace')} +
+
+ {counts.total} + {formatCount(counts.total, 'repo')} +
+
+ +
+
+

Badge Distribution

+

{counts.red + counts.orange} repos need review

+
+
+ + + + + +
+
+ +
+
+
+

Workspaces

+

{$currentWorkspace?.name ?? 'No workspace selected'}

+
+ {#if $workspaces.length === 0} + + {:else} +
+ {#each $workspaces as workspace} + {@const summary = workspaceCounts(workspace)} +
+ +
+ {workspace.name} + {workspace.root} +
+ 0 ? 'red' : summary.orange > 0 ? 'orange' : 'green'} count={summary.total} /> + +
+ {/each} +
+ {/if} +
+ +
+
+

Needs Attention

+

{formatCount(highRiskRepos.length, 'repo')}

+
+ {#if highRiskRepos.length === 0} + + {:else} +
+ {#each highRiskRepos as repo} +
+
+ {repoName(repo.path)} + {repo.org ?? repo.workspace ?? repo.path} +
+ + {repoChangeCount(repo)} changes + {repo.ahead} ahead / {repo.behind} behind +
+ {/each} +
+ {/if} +
+
+
+ + diff --git a/crates/git-same-app/ui/src/routes/FinderBadges.svelte b/crates/git-same-app/ui/src/routes/FinderBadges.svelte new file mode 100644 index 0000000..4760905 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/FinderBadges.svelte @@ -0,0 +1,319 @@ + + +
+
+
+

Setup Checklist

+

{setupRows.filter((row) => row.passed).length} / {setupRows.length} ready

+
+
+ {#each setupRows as row} +
+ + {#if row.passed}{:else}{/if} + +
+ {row.label} + {row.detail} +
+ {#if row.action && !row.passed} + + {/if} +
+ {/each} +
+
+ +
+
+
+

Badge Legend

+

Finder folder overlays

+
+
+
Clean, synced, and safe to mirror.
+
Synced, but important ignored local files exist.
+
Main is clean, but another branch or worktree diverges.
+
Uncommitted work, untracked files, or unpushed commits.
+
Ambient repo pending deeper classification.
+
+
+ +
+
+

Status File

+

Monitor output

+
+
+
+
Path
+
{$snapshot?.status_path ?? 'Unavailable'}
+
+
+
Last update
+
{relativeTime($snapshot?.updated_at)}
+
+
+
Monitor PID
+
{status?.daemon_pid ?? 'Unavailable'}
+
+
+
Repos visible
+
{status?.repos.length ?? 0}
+
+
+
+
+ +
+
+

Monitored Roots

+

{roots.length} paths

+
+ {#if roots.length === 0} + + {:else} +
+ {#each roots as root} +
+ + {root} +
+ {/each} +
+ {/if} +
+ +
+ + Finder badges update from the monitor status file. The app never deletes repositories when changing badge settings. +
+
+ + diff --git a/crates/git-same-app/ui/src/routes/Requirements.svelte b/crates/git-same-app/ui/src/routes/Requirements.svelte new file mode 100644 index 0000000..857a66f --- /dev/null +++ b/crates/git-same-app/ui/src/routes/Requirements.svelte @@ -0,0 +1,225 @@ + + +
+
+
+ {passed} + Passing checks +
+
0}> + {criticalFailures} + Critical failures +
+ +
+ +
+ {#if $requirements.length === 0} +
+ Run checks to inspect system requirements. +
+ {:else} + {#each $requirements as check} + {@const action = actionFor(check.name)} +
+ + {#if check.passed}{:else}{/if} + +
+ {check.name} + {check.message} + {#if check.suggestion && !check.passed} +

{check.suggestion}

+ {/if} +
+ + {check.critical ? 'Critical' : 'Optional'} + + {#if action && !check.passed} + + {/if} +
+ {/each} + {/if} +
+
+ + diff --git a/crates/git-same-app/ui/src/routes/Settings.svelte b/crates/git-same-app/ui/src/routes/Settings.svelte index 074c224..59ed6f1 100644 --- a/crates/git-same-app/ui/src/routes/Settings.svelte +++ b/crates/git-same-app/ui/src/routes/Settings.svelte @@ -1,58 +1,361 @@ -
-

Finder Badges

-
-
-
Status file
-
{$snapshot?.status_path ?? 'Unavailable'}
-
-
-
Last update
-
{relativeTime($snapshot?.updated_at)}
-
-
-
Monitor PID
-
{$snapshot?.status?.daemon_pid ?? 'Unavailable'}
-
-
-
+{#if !$appConfig} + + + +{:else if !$appConfig.exists} + + + +{:else} +
+
+
+

Global Config

+

{$appConfig.config_path}

+
+
+ + +
+
+ +
+

Sync Defaults

+ + + + + + +
+ +
+

Clone Defaults

+ + + +
+ +
+

Provider Filters

+ + + + +
+ +
+

Finder Ambient Badges

+ + + + +
+
+{/if} diff --git a/crates/git-same-app/ui/src/routes/Workspace.svelte b/crates/git-same-app/ui/src/routes/Workspace.svelte new file mode 100644 index 0000000..21e7978 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/Workspace.svelte @@ -0,0 +1,553 @@ + + +{#if loading} + +{:else} +
+
+
+

{workspaceId ? 'Workspace Details' : 'Create Workspace'}

+

{workspaceId ? configPath : 'A new .git-same/config.toml will be created inside the selected folder.'}

+
+
+ {#if workspaceId} + + {/if} + +
+
+ +
+

Location

+ + + +
+ +
+

Provider

+ + + + +
+ +
+
+ +
+

Repository Selection

+ + + + + + + +
+ +
+

Overrides

+ + + + + + {#if useCloneOverride} + + + + {/if} +
+
+{/if} + + diff --git a/crates/git-same-app/ui/src/routes/router.ts b/crates/git-same-app/ui/src/routes/router.ts index 55a2b7c..28948e7 100644 --- a/crates/git-same-app/ui/src/routes/router.ts +++ b/crates/git-same-app/ui/src/routes/router.ts @@ -1,9 +1,18 @@ import type { RouteDefinition } from 'svelte-spa-router'; +import BadgeBrowser from './BadgeBrowser.svelte'; import Dashboard from './Dashboard.svelte'; +import FinderBadges from './FinderBadges.svelte'; +import Requirements from './Requirements.svelte'; import Settings from './Settings.svelte'; +import Workspace from './Workspace.svelte'; export const routes: RouteDefinition = { '/': Dashboard, + '/dashboard': Dashboard, + '/finder-badges': FinderBadges, + '/badge-browser': BadgeBrowser, + '/workspace': Workspace, '/settings': Settings, + '/requirements': Requirements, '*': Dashboard, }; diff --git a/crates/git-same-app/ui/src/stores/status.ts b/crates/git-same-app/ui/src/stores/status.ts index a192643..e01c3fe 100644 --- a/crates/git-same-app/ui/src/stores/status.ts +++ b/crates/git-same-app/ui/src/stores/status.ts @@ -1,52 +1,122 @@ import { derived, get, writable } from 'svelte/store'; import { + checkRequirements, + deleteWorkspace, + ensureConfig, listWorkspaces, onStatusUpdated, onSyncProgress, + readAppConfig, readExtensionStatus, readStatus, + saveAppConfig, + setDefaultWorkspace, startSync, } from '../lib/tauri'; import type { + AppConfigDto, + AppConfigInput, ExtensionStatus, ProgressEvent, + RequirementCheckDto, StatusSnapshot, SyncProgressPayload, SyncProgressState, + WorkspaceInput, WorkspaceSummary, } from '../lib/types'; +import { saveWorkspace as saveWorkspaceCommand } from '../lib/tauri'; + +export const NEW_WORKSPACE_ID = '__new_workspace__'; export const snapshot = writable(null); export const workspaces = writable([]); export const extensionStatus = writable(null); +export const appConfig = writable(null); +export const requirements = writable([]); export const selectedWorkspaceId = writable(''); export const loading = writable(true); +export const requirementsLoading = writable(false); export const syncingId = writable(''); export const errorMessage = writable(''); +export const successMessage = writable(''); export const syncProgress = writable(null); export const currentWorkspace = derived( [workspaces, selectedWorkspaceId], - ([$workspaces, $selectedWorkspaceId]) => - $workspaces.find((workspace) => workspace.id === $selectedWorkspaceId) ?? - $workspaces[0], + ([$workspaces, $selectedWorkspaceId]) => { + if ($selectedWorkspaceId === NEW_WORKSPACE_ID) return undefined; + if ($selectedWorkspaceId) { + return $workspaces.find((workspace) => workspace.id === $selectedWorkspaceId); + } + return $workspaces.find((workspace) => workspace.default) ?? $workspaces[0]; + }, ); export async function refresh(): Promise { errorMessage.set(''); - const [workspaceList, status, ext] = await Promise.all([ + const [workspaceList, status, ext, config] = await Promise.all([ listWorkspaces(), readStatus(), readExtensionStatus().catch(() => null), + readAppConfig().catch(() => null), ]); workspaces.set(workspaceList); snapshot.set(status); extensionStatus.set(ext); - if (!get(selectedWorkspaceId) && workspaceList.length > 0) { - const defaultId = - workspaceList.find((workspace) => workspace.default)?.id ?? - workspaceList[0].id; - selectedWorkspaceId.set(defaultId); + appConfig.set(config); + reconcileSelectedWorkspace(workspaceList); +} + +export async function loadAppConfig(): Promise { + appConfig.set(await readAppConfig()); +} + +export async function createDefaultConfig(): Promise { + appConfig.set(await ensureConfig()); + await refresh(); +} + +export async function saveConfig(input: AppConfigInput): Promise { + errorMessage.set(''); + appConfig.set(await saveAppConfig(input)); + successMessage.set('Settings saved'); + await refresh(); +} + +export async function saveWorkspace(input: WorkspaceInput): Promise { + errorMessage.set(''); + const saved = await saveWorkspaceCommand(input); + selectedWorkspaceId.set(saved.id); + successMessage.set('Workspace saved'); + await refresh(); + return saved.id; +} + +export async function removeWorkspace(workspaceId: string): Promise { + errorMessage.set(''); + const next = await deleteWorkspace(workspaceId); + workspaces.set(next); + selectedWorkspaceId.set(next.find((workspace) => workspace.default)?.id ?? next[0]?.id ?? ''); + successMessage.set('Workspace metadata removed'); + await refresh(); +} + +export async function updateDefaultWorkspace(workspaceId: string | null): Promise { + errorMessage.set(''); + workspaces.set(await setDefaultWorkspace(workspaceId)); + await refresh(); +} + +export async function loadRequirements(): Promise { + requirementsLoading.set(true); + errorMessage.set(''); + try { + requirements.set(await checkRequirements()); + } catch (err) { + errorMessage.set(String(err)); + } finally { + requirementsLoading.set(false); } } @@ -92,6 +162,15 @@ export async function subscribePush(): Promise<() => void> { }; } +function reconcileSelectedWorkspace(workspaceList: WorkspaceSummary[]) { + const selected = get(selectedWorkspaceId); + if (selected === NEW_WORKSPACE_ID) return; + if (selected && workspaceList.some((workspace) => workspace.id === selected)) return; + selectedWorkspaceId.set( + workspaceList.find((workspace) => workspace.default)?.id ?? workspaceList[0]?.id ?? '', + ); +} + function reduceSyncProgress( current: SyncProgressState | null, payload: SyncProgressPayload, diff --git a/crates/git-same-app/ui/src/styles/tokens.css b/crates/git-same-app/ui/src/styles/tokens.css index 42d1f81..f8622fb 100644 --- a/crates/git-same-app/ui/src/styles/tokens.css +++ b/crates/git-same-app/ui/src/styles/tokens.css @@ -2,42 +2,50 @@ color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #f7f5ef; - color: #1f2528; + background: #f5f6f8; + color: #1d232b; font-synthesis: none; text-rendering: optimizeLegibility; - --bg: #f7f5ef; + --bg: #f5f6f8; + --sidebar: #eef1f5; --panel: #ffffff; - --panel-alt: #eef3f1; - --line: #d7ded9; - --text: #1f2528; - --muted: #687276; - --accent: #0f766e; - --accent-strong: #0b5f59; - --danger: #ba3f38; + --panel-alt: #f1f3f6; + --hover: #e6ebf2; + --selected: #dce9fb; + --line: #d8dee8; + --line-strong: #c5ceda; + --text: #1d232b; + --muted: #66717f; + --accent: #246fc8; + --accent-strong: #165bb0; + --danger: #c23b3b; --warning: #b66a12; --ok: #2f7d50; - --blue: #236fb1; - --shadow: 0 12px 28px rgba(32, 38, 35, 0.12); + --blue: #246fc8; + --shadow: 0 10px 24px rgba(30, 40, 54, 0.08); } @media (prefers-color-scheme: dark) { :root { - background: #111719; - color: #edf1ef; - --bg: #111719; - --panel: #192123; - --panel-alt: #222d2c; - --line: #33403f; - --text: #edf1ef; - --muted: #a8b2b0; - --accent: #4db6ac; - --accent-strong: #73d6cb; - --danger: #ff7f76; - --warning: #e6a23c; - --ok: #6ec48e; - --blue: #79aee8; - --shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + background: #111418; + color: #eef2f6; + --bg: #111418; + --sidebar: #171b21; + --panel: #1d2229; + --panel-alt: #252b34; + --hover: #2b3340; + --selected: #19375f; + --line: #333b47; + --line-strong: #475160; + --text: #eef2f6; + --muted: #a4adba; + --accent: #68a8ff; + --accent-strong: #94c2ff; + --danger: #ff817d; + --warning: #e7a646; + --ok: #70c78f; + --blue: #84b8f5; + --shadow: 0 12px 28px rgba(0, 0, 0, 0.24); } } @@ -56,6 +64,12 @@ button { font: inherit; } +input, +select, +textarea { + font: inherit; +} + .spinning { animation: spin 1s linear infinite; } From f92f177095bd04b96d300be7f66fe9c6d50eb552 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 19:04:34 +0200 Subject: [PATCH 55/89] Fix Tauri dev server port collision to prevent wrong UI loading --- crates/git-same-app/ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json index 27abbe6..33c5535 100644 --- a/crates/git-same-app/ui/package.json +++ b/crates/git-same-app/ui/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@11.0.8", "scripts": { - "dev": "vite --host 127.0.0.1 --port 1420", + "dev": "vite --host 127.0.0.1 --port 1420 --strictPort", "build": "vite build", "check": "svelte-check --tsconfig ./tsconfig.json" }, From 0d3ca6b3513684ad50078c9c53e51c6673899732 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 19:15:59 +0200 Subject: [PATCH 56/89] Fix Conductor Tauri launch to use workspace dev port --- crates/git-same-app/tauri.conf.json | 2 +- crates/git-same-app/ui/package.json | 2 +- toolkit/conductor/run.sh | 20 +++++++++++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/git-same-app/tauri.conf.json b/crates/git-same-app/tauri.conf.json index ca4a38a..cdb336f 100644 --- a/crates/git-same-app/tauri.conf.json +++ b/crates/git-same-app/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "corepack pnpm dev", "beforeBuildCommand": "corepack pnpm build", - "devUrl": "http://localhost:1420", + "devUrl": "http://127.0.0.1:1420", "frontendDist": "ui/dist" }, "app": { diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json index 33c5535..552eb7b 100644 --- a/crates/git-same-app/ui/package.json +++ b/crates/git-same-app/ui/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@11.0.8", "scripts": { - "dev": "vite --host 127.0.0.1 --port 1420 --strictPort", + "dev": "vite --host 127.0.0.1 --port ${GIT_SAME_APP_PORT:-${CONDUCTOR_PORT:-${PORT:-1420}}} --strictPort", "build": "vite build", "check": "svelte-check --tsconfig ./tsconfig.json" }, diff --git a/toolkit/conductor/run.sh b/toolkit/conductor/run.sh index 0a1fb02..2d626fe 100755 --- a/toolkit/conductor/run.sh +++ b/toolkit/conductor/run.sh @@ -137,9 +137,27 @@ if [ ! -x "$TAURI_CLI" ]; then exit 1 fi +APP_PORT="${GIT_SAME_APP_PORT:-${CONDUCTOR_PORT:-${PORT:-1420}}}" +if ! [[ "$APP_PORT" =~ ^[0-9]+$ ]] || [ "$APP_PORT" -lt 1 ] || [ "$APP_PORT" -gt 65535 ]; then + echo "ERROR: Invalid app port '$APP_PORT'. Set GIT_SAME_APP_PORT to a value from 1-65535." + exit 1 +fi +export GIT_SAME_APP_PORT="$APP_PORT" + +TAURI_DEV_CONFIG="$(mktemp -t git-same-tauri-dev.XXXXXX.json)" +trap 'rm -f "$TAURI_DEV_CONFIG"' EXIT +cat > "$TAURI_DEV_CONFIG" < Date: Fri, 8 May 2026 19:38:50 +0200 Subject: [PATCH 57/89] Add monitor LaunchAgent repair actions to restore Finder badges --- crates/git-same-app/src/commands.rs | 516 +++++++++++- crates/git-same-app/src/commands_tests.rs | 111 +++ crates/git-same-app/src/main.rs | 4 + crates/git-same-app/ui/src/lib/Banner.svelte | 10 +- crates/git-same-app/ui/src/lib/Sidebar.svelte | 2 +- .../ui/src/lib/StatusBanner.svelte | 10 +- .../git-same-app/ui/src/lib/TitleBar.svelte | 14 +- crates/git-same-app/ui/src/lib/tauri.ts | 20 + crates/git-same-app/ui/src/lib/types.ts | 32 + .../ui/src/routes/FinderBadges.svelte | 18 +- .../ui/src/routes/Requirements.svelte | 26 +- .../ui/src/routes/Workspace.svelte | 794 ++++++++---------- .../ui/src/routes/WorkspaceScreen.svelte | 606 +++++++++++++ crates/git-same-app/ui/src/routes/router.ts | 2 + crates/git-same-app/ui/src/stores/status.ts | 54 ++ 15 files changed, 1710 insertions(+), 509 deletions(-) create mode 100644 crates/git-same-app/ui/src/routes/WorkspaceScreen.svelte diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs index 961d3f8..ac7d0cc 100644 --- a/crates/git-same-app/src/commands.rs +++ b/crates/git-same-app/src/commands.rs @@ -1,22 +1,30 @@ use git_same_core::api::RepoScanService; +use git_same_core::auth::get_auth_for_provider; +use git_same_core::cache::{CacheManager, DiscoveryCache}; use git_same_core::checks::CheckResult; use git_same_core::config::workspace::tilde_collapse_path; use git_same_core::config::{ Config, ConfigCloneOptions, FilterOptions, SyncMode, WorkspaceConfig, WorkspaceManager, WorkspaceProvider, }; +use git_same_core::discovery::DiscoveryOrchestrator; +use git_same_core::domain::RepoPathTemplate; use git_same_core::errors::AppError; use git_same_core::git::ShellGit; use git_same_core::ipc::{IpcConfig, StatusFileWriter}; use git_same_core::progress::{ProgressEvent, ProgressReporter}; +use git_same_core::provider::{create_provider, NoProgress}; use git_same_core::setup::{authenticate_provider, discover_org_entries}; -use git_same_core::types::{FinderStatus, ProviderKind}; +use git_same_core::types::{FinderStatus, OwnedRepo, ProviderKind}; use git_same_core::workflows::sync_workspace::{ execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest, }; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, SystemTime}; @@ -24,6 +32,9 @@ use tauri::Emitter; const DAEMON_STALE_AFTER_SECS: u64 = 90; const FINDER_EXTENSION_ID: &str = "com.zaai.git-same.Badges"; +const MONITOR_LAUNCH_AGENT_LABEL: &str = "com.zaai.git-same.monitor"; +const MONITOR_LAUNCH_AGENT_FILE: &str = "com.zaai.git-same.monitor.plist"; +const MONITOR_PLIST_TEMPLATE: &str = include_str!("../../../macos/com.zaai.git-same.monitor.plist"); #[cfg(test)] #[path = "commands_tests.rs"] @@ -160,6 +171,29 @@ pub struct ProviderOrgDto { pub selected: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WorkspaceStructureDto { + pub workspace_id: String, + pub name: String, + pub root: String, + pub provider: String, + pub host: String, + pub source: String, + pub cache_age_secs: Option, + pub error: Option, + pub repos: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WorkspaceStructureRepoDto { + pub owner: String, + pub name: String, + pub full_name: String, + pub url: String, + pub local_path: String, + pub local_exists: bool, +} + #[derive(Debug, Clone, Serialize)] pub struct StatusSnapshot { pub status_path: String, @@ -174,6 +208,18 @@ pub struct ExtensionStatus { pub enabled: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MonitorLaunchAgentStatusDto { + pub label: String, + pub plist_path: String, + pub binary_path: Option, + pub installed: bool, + pub loaded: bool, + pub running: bool, + pub state: String, + pub message: String, +} + #[derive(Debug, Clone, Serialize)] pub struct SyncProgressPayload { pub workspace_id: String, @@ -317,6 +363,21 @@ pub async fn check_requirements() -> Result, String> { Ok(checks) } +#[tauri::command] +pub fn monitor_launch_agent_status() -> Result { + monitor_launch_agent_status_inner().map_err(error_string) +} + +#[tauri::command] +pub fn install_monitor_launch_agent() -> Result { + install_monitor_launch_agent_inner().map_err(error_string) +} + +#[tauri::command] +pub fn restart_monitor_launch_agent() -> Result { + restart_monitor_launch_agent_inner().map_err(error_string) +} + #[tauri::command] pub async fn discover_provider_orgs( provider: WorkspaceProviderDto, @@ -343,6 +404,15 @@ pub async fn discover_provider_orgs( }) } +#[tauri::command] +pub async fn read_workspace_structure( + workspace_id: String, +) -> Result { + read_workspace_structure_inner(workspace_id) + .await + .map_err(error_string) +} + #[tauri::command] pub async fn read_status() -> Result { read_status_snapshot().map_err(error_string) @@ -447,6 +517,241 @@ pub fn open_url(url: String) -> Result<(), String> { } } +fn monitor_launch_agent_status_inner() -> Result { + let plist_path = monitor_launch_agent_path()?; + let installed = plist_path.exists(); + let binary_path = monitor_binary_path().ok(); + let launchctl = launchctl_print_monitor(); + let loaded = launchctl + .as_ref() + .is_ok_and(|output| output.status.success()); + let stdout = launchctl + .as_ref() + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).to_string()) + .unwrap_or_default(); + let running = loaded && launchctl_output_has_pid(&stdout); + let state = monitor_agent_state(installed, loaded, running); + Ok(MonitorLaunchAgentStatusDto { + label: MONITOR_LAUNCH_AGENT_LABEL.to_string(), + plist_path: plist_path.display().to_string(), + binary_path: binary_path.map(|path| path.display().to_string()), + installed, + loaded, + running, + message: monitor_agent_message(&state), + state, + }) +} + +fn install_monitor_launch_agent_inner() -> Result { + let plist_path = monitor_launch_agent_path()?; + let binary_path = monitor_binary_path()?; + let rendered = render_monitor_plist(&binary_path)?; + if let Some(parent) = plist_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + AppError::path(format!( + "Failed to create LaunchAgents directory '{}': {error}", + parent.display() + )) + })?; + } + fs::write(&plist_path, rendered).map_err(|error| { + AppError::path(format!( + "Failed to write LaunchAgent '{}': {error}", + plist_path.display() + )) + })?; + restart_monitor_launch_agent_inner() +} + +fn restart_monitor_launch_agent_inner() -> Result { + let plist_path = monitor_launch_agent_path()?; + if !plist_path.exists() { + return install_monitor_launch_agent_inner(); + } + let domain = launchctl_domain(); + let service = format!("{domain}/{MONITOR_LAUNCH_AGENT_LABEL}"); + let plist_arg = plist_path.display().to_string(); + let _ = Command::new("/bin/launchctl") + .args(["bootout", &domain, &plist_arg]) + .output(); + run_launchctl(&["bootstrap", &domain, &plist_arg])?; + run_launchctl(&["kickstart", "-k", &service])?; + monitor_launch_agent_status_inner() +} + +fn monitor_launch_agent_path() -> Result { + let home = env::var_os("HOME") + .ok_or_else(|| AppError::config("HOME is not set; cannot resolve LaunchAgents path"))?; + Ok(PathBuf::from(home) + .join("Library") + .join("LaunchAgents") + .join(MONITOR_LAUNCH_AGENT_FILE)) +} + +fn monitor_binary_path() -> Result { + for candidate in monitor_binary_candidates() { + if candidate.is_file() && is_executable(&candidate) { + return Ok(candidate); + } + } + Err(AppError::config( + "Could not find an executable git-same binary for the monitor LaunchAgent", + )) +} + +fn monitor_binary_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Ok(exe) = env::current_exe() { + if let Some(contents) = exe.ancestors().find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "Contents") + }) { + candidates.push(contents.join("Helpers").join("git-same")); + } + if let Some(parent) = exe.parent() { + candidates.push(parent.join("git-same")); + } + } + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + candidates.push(home.join(".cargo/bin/git-same")); + candidates.push(home.join(".cargo/bin/gisa")); + } + if let Some(path) = find_on_path("git-same") { + candidates.push(path); + } + if let Some(path) = find_on_path("gisa") { + candidates.push(path); + } + dedupe_paths(candidates) +} + +fn render_monitor_plist(binary_path: &Path) -> Result { + if !binary_path.is_file() || !is_executable(binary_path) { + return Err(AppError::config(format!( + "Monitor binary is not executable: {}", + binary_path.display() + ))); + } + Ok(MONITOR_PLIST_TEMPLATE.replace( + "__GIT_SAME_MONITOR_BINARY__", + &escape_xml(&binary_path.display().to_string()), + )) +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + path.is_file() + } +} + +fn find_on_path(binary: &str) -> Option { + let paths = env::var_os("PATH")?; + env::split_paths(&paths) + .map(|dir| dir.join(binary)) + .find(|path| path.is_file() && is_executable(path)) +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut deduped = Vec::new(); + for path in paths { + if !deduped.iter().any(|existing| existing == &path) { + deduped.push(path); + } + } + deduped +} + +fn launchctl_print_monitor() -> std::io::Result { + Command::new("/bin/launchctl") + .args([ + "print", + &format!("{}/{}", launchctl_domain(), MONITOR_LAUNCH_AGENT_LABEL), + ]) + .output() +} + +fn run_launchctl(args: &[&str]) -> Result<(), AppError> { + let output = Command::new("/bin/launchctl") + .args(args) + .output() + .map_err(|error| AppError::config(format!("launchctl failed to start: {error}")))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(AppError::config(format!( + "launchctl {} failed: {}{}", + args.join(" "), + stdout, + stderr + ))) +} + +fn launchctl_domain() -> String { + let uid = Command::new("/usr/bin/id") + .arg("-u") + .output() + .ok() + .and_then(|output| output.status.success().then_some(output.stdout)) + .and_then(|stdout| String::from_utf8(stdout).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "0".to_string()); + format!("gui/{uid}") +} + +fn launchctl_output_has_pid(output: &str) -> bool { + output.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with("pid = ") || trimmed.starts_with("pid =") + }) +} + +fn monitor_agent_state(installed: bool, loaded: bool, running: bool) -> String { + if !installed { + "missing_plist" + } else if !loaded { + "not_loaded" + } else if !running { + "not_running" + } else { + "running" + } + .to_string() +} + +fn monitor_agent_message(state: &str) -> String { + match state { + "missing_plist" => "LaunchAgent plist is missing".to_string(), + "not_loaded" => "LaunchAgent is installed but not loaded".to_string(), + "not_running" => "LaunchAgent is loaded but the monitor is not running".to_string(), + "running" => "Monitor LaunchAgent is running".to_string(), + _ => "Unknown monitor LaunchAgent state".to_string(), + } +} + // `pluginkit -m -v -i ` prints one line per plugin matching the id, or // nothing if no match. Each line begins with `+` (enabled) or `-` (disabled), // followed by the plugin id and bundle path. We treat any line containing @@ -659,18 +964,13 @@ fn app_requirement_checks() -> Vec { }]; let snapshot = read_status_snapshot().ok(); + let monitor_agent = monitor_launch_agent_status_inner().ok(); checks.push(RequirementCheckDto { name: "Monitor".to_string(), - passed: snapshot.as_ref().is_some_and(|snapshot| !snapshot.stale), - message: snapshot - .as_ref() - .and_then(|snapshot| snapshot.updated_at.clone()) - .unwrap_or_else(|| "no fresh status file".to_string()), - suggestion: snapshot - .as_ref() - .map(|snapshot| snapshot.stale) - .unwrap_or(true) - .then(|| "Load the Git-Same LaunchAgent or run gisa monitor --foreground".to_string()), + passed: monitor_agent.as_ref().is_some_and(|agent| agent.running) + && snapshot.as_ref().is_some_and(|snapshot| !snapshot.stale), + message: monitor_requirement_message(monitor_agent.as_ref(), snapshot.as_ref()), + suggestion: monitor_requirement_suggestion(monitor_agent.as_ref(), snapshot.as_ref()), critical: false, }); @@ -716,6 +1016,200 @@ fn app_requirement_checks() -> Vec { checks } +fn monitor_requirement_message( + agent: Option<&MonitorLaunchAgentStatusDto>, + snapshot: Option<&StatusSnapshot>, +) -> String { + match agent { + Some(agent) if !agent.installed => "LaunchAgent plist missing".to_string(), + Some(agent) if !agent.loaded => "LaunchAgent installed but not loaded".to_string(), + Some(agent) if !agent.running => { + "LaunchAgent loaded but monitor process is not running".to_string() + } + Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => { + "Monitor running but status file is stale".to_string() + } + Some(_) => snapshot + .and_then(|snapshot| snapshot.updated_at.clone()) + .unwrap_or_else(|| "Monitor running".to_string()), + None => "Unable to inspect LaunchAgent".to_string(), + } +} + +fn monitor_requirement_suggestion( + agent: Option<&MonitorLaunchAgentStatusDto>, + snapshot: Option<&StatusSnapshot>, +) -> Option { + match agent { + Some(agent) if !agent.installed => { + Some("Install the Git-Same monitor LaunchAgent".to_string()) + } + Some(agent) if !agent.loaded || !agent.running => { + Some("Restart the Git-Same monitor LaunchAgent".to_string()) + } + Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => { + Some("Restart the monitor or wait for the next scan".to_string()) + } + Some(_) => None, + None => Some("Check LaunchAgent permissions and the git-same binary path".to_string()), + } +} + +async fn read_workspace_structure_inner( + workspace_id: String, +) -> Result { + let config = Config::load()?; + let workspace = WorkspaceManager::resolve(Some(&workspace_id), &config)?; + let base_path = workspace.expanded_base_path(); + let structure = workspace + .structure + .clone() + .unwrap_or_else(|| config.structure.clone()); + let provider_name = workspace.provider.kind.slug().to_string(); + let orchestrator = workspace_orchestrator(&workspace, &config, structure.clone()); + + let mut source = "cache".to_string(); + let mut cache_age_secs = None; + let mut error = None; + let repos = match load_structure_cache(&workspace, &orchestrator) { + Ok(Some((repos, age_secs))) => { + cache_age_secs = Some(age_secs); + repos + } + Ok(None) | Err(_) => match discover_structure_repos(&workspace, &orchestrator).await { + Ok(repos) => { + source = "remote".to_string(); + save_structure_cache(&workspace, &provider_name, &repos); + repos + } + Err(err) => { + source = "unavailable".to_string(); + error = Some(err); + Vec::new() + } + }, + }; + + Ok(WorkspaceStructureDto { + workspace_id: tilde_collapse_path(&workspace.root_path), + name: workspace_name(&workspace.root_path), + root: base_path.display().to_string(), + provider: workspace.provider.kind.display_name().to_string(), + host: provider_host(&workspace.provider), + source, + cache_age_secs, + error, + repos: structure_repo_dtos(&repos, &base_path, &provider_name, &structure), + }) +} + +fn workspace_orchestrator( + workspace: &WorkspaceConfig, + _config: &Config, + structure: String, +) -> DiscoveryOrchestrator { + let mut filters = workspace.filters.clone(); + if !workspace.orgs.is_empty() { + filters.orgs = workspace.orgs.clone(); + } + filters.exclude_repos = workspace.exclude_repos.clone(); + DiscoveryOrchestrator::new(filters, structure) +} + +fn load_structure_cache( + workspace: &WorkspaceConfig, + orchestrator: &DiscoveryOrchestrator, +) -> anyhow::Result, u64)>> { + let Some(cache) = CacheManager::for_workspace(&workspace.root_path)?.load()? else { + return Ok(None); + }; + let age_secs = cache.age_secs(); + let options = orchestrator.to_discovery_options(); + let repos = cache + .repos + .values() + .flat_map(|provider_repos| provider_repos.iter()) + .filter(|owned| { + options.should_include_org(&owned.owner) && options.should_include(&owned.repo) + }) + .cloned() + .collect(); + Ok(Some((repos, age_secs))) +} + +async fn discover_structure_repos( + workspace: &WorkspaceConfig, + orchestrator: &DiscoveryOrchestrator, +) -> Result, String> { + let provider_cfg = workspace.provider.clone(); + let auth = tokio::task::spawn_blocking(move || get_auth_for_provider(&provider_cfg)) + .await + .map_err(|err| format!("Auth task failed: {err}"))? + .map_err(|err| err.to_string())?; + let provider = + create_provider(&workspace.provider, &auth.token).map_err(|err| err.to_string())?; + orchestrator + .discover(provider.as_ref(), &NoProgress) + .await + .map_err(|err| err.to_string()) +} + +fn save_structure_cache(workspace: &WorkspaceConfig, provider_name: &str, repos: &[OwnedRepo]) { + let Ok(cache_manager) = CacheManager::for_workspace(&workspace.root_path) else { + return; + }; + let mut repos_by_provider = HashMap::new(); + repos_by_provider.insert(provider_name.to_string(), repos.to_vec()); + let cache = DiscoveryCache::new(workspace.username.clone(), repos_by_provider); + let _ = cache_manager.save(&cache); +} + +fn structure_repo_dtos( + repos: &[OwnedRepo], + base_path: &Path, + provider_name: &str, + structure: &str, +) -> Vec { + let template = RepoPathTemplate::new(structure.to_string()); + let mut dtos: Vec<_> = repos + .iter() + .map(|owned| { + let local_path = template.render_owned_repo(base_path, owned, provider_name); + WorkspaceStructureRepoDto { + owner: owned.owner.clone(), + name: owned.repo.name.clone(), + full_name: owned.repo.full_name.clone(), + url: repo_url(owned), + local_exists: local_path.exists(), + local_path: local_path.display().to_string(), + } + }) + .collect(); + dtos.sort_by(|left, right| left.full_name.cmp(&right.full_name)); + dtos +} + +fn repo_url(owned: &OwnedRepo) -> String { + if !owned.repo.clone_url.is_empty() { + return owned.repo.clone_url.trim_end_matches(".git").to_string(); + } + format!("https://github.com/{}", owned.repo.full_name) +} + +fn provider_host(provider: &WorkspaceProvider) -> String { + match provider.kind { + ProviderKind::GitHub => "github.com".to_string(), + _ => provider + .api_url + .clone() + .unwrap_or_else(|| provider.kind.default_api_url().to_string()) + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/') + .to_string(), + } +} + fn requirement_check_dto(check: CheckResult) -> RequirementCheckDto { RequirementCheckDto { name: check.name, diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs index a957bca..1968a60 100644 --- a/crates/git-same-app/src/commands_tests.rs +++ b/crates/git-same-app/src/commands_tests.rs @@ -130,6 +130,67 @@ fn workspace_input(root: &std::path::Path) -> WorkspaceInput { } } +#[test] +fn render_monitor_plist_replaces_binary_placeholder() { + let temp = TestDir::new("monitor-plist"); + let binary = temp.path().join("git-same"); + std::fs::write(&binary, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&binary).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&binary, permissions).unwrap(); + } + + let rendered = render_monitor_plist(&binary).unwrap(); + + assert!(rendered.contains(&binary.display().to_string())); + assert!(!rendered.contains("__GIT_SAME_MONITOR_BINARY__")); + assert!(rendered.contains("com.zaai.git-same.monitor")); +} + +#[test] +fn render_monitor_plist_rejects_non_executable_binary() { + let temp = TestDir::new("monitor-plist-invalid"); + let binary = temp.path().join("git-same"); + std::fs::write(&binary, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&binary).unwrap().permissions(); + permissions.set_mode(0o644); + std::fs::set_permissions(&binary, permissions).unwrap(); + } + + let error = render_monitor_plist(&binary).unwrap_err().to_string(); + + assert!(error.contains("not executable")); +} + +#[test] +fn monitor_requirement_message_distinguishes_missing_plist() { + let agent = MonitorLaunchAgentStatusDto { + label: MONITOR_LAUNCH_AGENT_LABEL.to_string(), + plist_path: "/tmp/missing.plist".to_string(), + binary_path: None, + installed: false, + loaded: false, + running: false, + state: "missing_plist".to_string(), + message: "LaunchAgent plist is missing".to_string(), + }; + + assert_eq!( + monitor_requirement_message(Some(&agent), None), + "LaunchAgent plist missing" + ); + assert_eq!( + monitor_requirement_suggestion(Some(&agent), None), + Some("Install the Git-Same monitor LaunchAgent".to_string()) + ); +} + #[test] fn read_status_snapshot_scans_foreground_when_status_file_is_missing() { let temp = TestDir::new("missing-status"); @@ -301,6 +362,56 @@ fn save_workspace_can_clear_existing_default() { assert!(Config::load().unwrap().default_workspace.is_none()); } +#[tokio::test] +async fn read_workspace_structure_uses_discovery_cache() { + let temp = TestDir::new("workspace-structure"); + let _env = ConfigEnvGuard::new(temp.path()); + ensure_config().unwrap(); + let root = temp.path().join("workspace"); + let detail = save_workspace(WorkspaceInput { + structure: Some("{provider}/{org}/{repo}".to_string()), + ..workspace_input(&root) + }) + .unwrap(); + let local_repo = root.join("github/acme/widgets"); + std::fs::create_dir_all(&local_repo).unwrap(); + + let repo = git_same_core::types::Repo { + id: 42, + name: "widgets".to_string(), + full_name: "acme/widgets".to_string(), + ssh_url: "git@github.com:acme/widgets.git".to_string(), + clone_url: "https://github.com/acme/widgets.git".to_string(), + default_branch: "main".to_string(), + private: false, + archived: false, + fork: false, + pushed_at: None, + description: None, + }; + let cache = DiscoveryCache::new( + "manuel".to_string(), + HashMap::from([("github".to_string(), vec![OwnedRepo::new("acme", repo)])]), + ); + CacheManager::for_workspace(&root) + .unwrap() + .save(&cache) + .unwrap(); + + let structure = read_workspace_structure_inner(detail.id).await.unwrap(); + + assert_eq!(structure.source, "cache"); + assert_eq!(structure.host, "github.com"); + assert_eq!(structure.repos.len(), 1); + assert_eq!(structure.repos[0].full_name, "acme/widgets"); + assert_eq!(structure.repos[0].url, "https://github.com/acme/widgets"); + assert_eq!( + std::fs::canonicalize(&structure.repos[0].local_path).unwrap(), + std::fs::canonicalize(&local_repo).unwrap() + ); + assert!(structure.repos[0].local_exists); +} + #[test] fn requirement_check_dto_maps_core_result() { let dto = requirement_check_dto(CheckResult { diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs index 10f9372..211f05e 100644 --- a/crates/git-same-app/src/main.rs +++ b/crates/git-same-app/src/main.rs @@ -14,7 +14,11 @@ fn main() { commands::delete_workspace, commands::set_default_workspace, commands::check_requirements, + commands::monitor_launch_agent_status, + commands::install_monitor_launch_agent, + commands::restart_monitor_launch_agent, commands::discover_provider_orgs, + commands::read_workspace_structure, commands::read_status, commands::start_sync, commands::extension_status, diff --git a/crates/git-same-app/ui/src/lib/Banner.svelte b/crates/git-same-app/ui/src/lib/Banner.svelte index c084edf..16afef4 100644 --- a/crates/git-same-app/ui/src/lib/Banner.svelte +++ b/crates/git-same-app/ui/src/lib/Banner.svelte @@ -3,6 +3,7 @@ import { errorMessage, extensionStatus, + installMonitor, snapshot, syncProgress, workspaces, @@ -86,7 +87,7 @@ {:else if showAllowExt} {:else} {#each $requirements as check} - {@const action = actionFor(check.name)} + {@const action = actionFor(check.name, check.message)}
{#if check.passed}{:else}{/if} @@ -75,8 +89,8 @@ {#if action && !check.passed} {/if}
diff --git a/crates/git-same-app/ui/src/routes/Workspace.svelte b/crates/git-same-app/ui/src/routes/Workspace.svelte index 21e7978..15dfda0 100644 --- a/crates/git-same-app/ui/src/routes/Workspace.svelte +++ b/crates/git-same-app/ui/src/routes/Workspace.svelte @@ -1,553 +1,417 @@ -{#if loading} - -{:else} -
-
-
-

{workspaceId ? 'Workspace Details' : 'Create Workspace'}

-

{workspaceId ? configPath : 'A new .git-same/config.toml will be created inside the selected folder.'}

-
-
- {#if workspaceId} - - {/if} - -
+
+ + + {#if !$currentWorkspace} + + + + {:else} +
+
+ {formatCount(remoteRepos.length, 'GitHub repo')} + {sourceLabel()} +
+
+ {formatCount(localRepos.length, 'local repo')} + {$currentWorkspace.root} +
+
+ {missingCount} + Missing locally +
+
+ {localOnlyCount} + Local only +
-
-

Location

-
+ +
+
+
+

Filesystem Structure

+

{$currentWorkspace.root}

+
+ +
-
-

Overrides

- - - - - - {#if useCloneOverride} - - - - {/if} -
- -{/if} + {#if localGroups.length === 0} + + {:else} +
+
+ + {$currentWorkspace.name} +
+ {#each localGroups as group} +
+ ├─ + {group.owner} +
+ {#each group.repos as repo} +
+ │ ├─ + {repoName(repo.path)} + + {badgeLabel(repo.badge)} +
+ {/each} + {/each} +
+ {/if} +
+ + {/if} +
diff --git a/crates/git-same-app/ui/src/routes/WorkspaceScreen.svelte b/crates/git-same-app/ui/src/routes/WorkspaceScreen.svelte new file mode 100644 index 0000000..c2cf742 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/WorkspaceScreen.svelte @@ -0,0 +1,606 @@ + + +
+ + + {#if loading} + + {:else} +
+
+
+

{workspaceId ? 'Workspace Details' : 'Create Workspace'}

+

{workspaceId ? configPath : 'A new .git-same/config.toml will be created inside the selected folder.'}

+
+
+ {#if workspaceId} + + {/if} + +
+
+ +
+

Location

+ + + +
+ +
+

Provider

+ + + + +
+ +
+
+ +
+

Repository Selection

+ + + + + + + +
+ +
+

Overrides

+ + + + + + {#if useCloneOverride} + + + + {/if} +
+
+ {/if} +
+ + diff --git a/crates/git-same-app/ui/src/routes/router.ts b/crates/git-same-app/ui/src/routes/router.ts index 28948e7..375f158 100644 --- a/crates/git-same-app/ui/src/routes/router.ts +++ b/crates/git-same-app/ui/src/routes/router.ts @@ -5,6 +5,7 @@ import FinderBadges from './FinderBadges.svelte'; import Requirements from './Requirements.svelte'; import Settings from './Settings.svelte'; import Workspace from './Workspace.svelte'; +import WorkspaceScreen from './WorkspaceScreen.svelte'; export const routes: RouteDefinition = { '/': Dashboard, @@ -12,6 +13,7 @@ export const routes: RouteDefinition = { '/finder-badges': FinderBadges, '/badge-browser': BadgeBrowser, '/workspace': Workspace, + '/workspace/screen': WorkspaceScreen, '/settings': Settings, '/requirements': Requirements, '*': Dashboard, diff --git a/crates/git-same-app/ui/src/stores/status.ts b/crates/git-same-app/ui/src/stores/status.ts index e01c3fe..f1b60f8 100644 --- a/crates/git-same-app/ui/src/stores/status.ts +++ b/crates/git-same-app/ui/src/stores/status.ts @@ -3,12 +3,15 @@ import { checkRequirements, deleteWorkspace, ensureConfig, + installMonitorLaunchAgent, listWorkspaces, onStatusUpdated, onSyncProgress, readAppConfig, readExtensionStatus, readStatus, + readWorkspaceStructure, + restartMonitorLaunchAgent, saveAppConfig, setDefaultWorkspace, startSync, @@ -23,6 +26,7 @@ import type { SyncProgressPayload, SyncProgressState, WorkspaceInput, + WorkspaceStructureDto, WorkspaceSummary, } from '../lib/types'; import { saveWorkspace as saveWorkspaceCommand } from '../lib/tauri'; @@ -34,6 +38,8 @@ export const workspaces = writable([]); export const extensionStatus = writable(null); export const appConfig = writable(null); export const requirements = writable([]); +export const workspaceStructure = writable(null); +export const workspaceStructureLoading = writable(false); export const selectedWorkspaceId = writable(''); export const loading = writable(true); export const requirementsLoading = writable(false); @@ -90,6 +96,7 @@ export async function saveWorkspace(input: WorkspaceInput): Promise { selectedWorkspaceId.set(saved.id); successMessage.set('Workspace saved'); await refresh(); + await loadCurrentWorkspaceStructure(); return saved.id; } @@ -100,6 +107,7 @@ export async function removeWorkspace(workspaceId: string): Promise { selectedWorkspaceId.set(next.find((workspace) => workspace.default)?.id ?? next[0]?.id ?? ''); successMessage.set('Workspace metadata removed'); await refresh(); + await loadCurrentWorkspaceStructure(); } export async function updateDefaultWorkspace(workspaceId: string | null): Promise { @@ -120,6 +128,34 @@ export async function loadRequirements(): Promise { } } +export async function installMonitor(): Promise { + requirementsLoading.set(true); + errorMessage.set(''); + try { + await installMonitorLaunchAgent(); + successMessage.set('Monitor LaunchAgent installed'); + await Promise.all([refresh(), loadRequirements()]); + } catch (err) { + errorMessage.set(String(err)); + } finally { + requirementsLoading.set(false); + } +} + +export async function restartMonitor(): Promise { + requirementsLoading.set(true); + errorMessage.set(''); + try { + await restartMonitorLaunchAgent(); + successMessage.set('Monitor LaunchAgent restarted'); + await Promise.all([refresh(), loadRequirements()]); + } catch (err) { + errorMessage.set(String(err)); + } finally { + requirementsLoading.set(false); + } +} + export async function startSyncCurrent(): Promise { const workspace = get(currentWorkspace); if (!workspace) return; @@ -137,6 +173,7 @@ export async function startSyncCurrent(): Promise { const next = await startSync(workspace.id); snapshot.set(next); await refresh(); + await loadCurrentWorkspaceStructure(); } catch (err) { errorMessage.set(String(err)); } finally { @@ -149,6 +186,23 @@ export async function startSyncCurrent(): Promise { } } +export async function loadCurrentWorkspaceStructure(): Promise { + const workspace = get(currentWorkspace); + if (!workspace) { + workspaceStructure.set(null); + return; + } + workspaceStructureLoading.set(true); + try { + workspaceStructure.set(await readWorkspaceStructure(workspace.id)); + } catch (err) { + errorMessage.set(String(err)); + workspaceStructure.set(null); + } finally { + workspaceStructureLoading.set(false); + } +} + export async function subscribePush(): Promise<() => void> { const unsubscribeStatus = await onStatusUpdated((next) => { snapshot.set(next); From 6196a7ff63dcf531234b03ae8480461d7b3f6f66 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 May 2026 21:15:04 +0200 Subject: [PATCH 58/89] Simplify brand logo to gradient wordmark in Tauri app UI --- crates/git-same-app/ui/src/App.svelte | 1 + .../git-same-app/ui/src/lib/BrandLogo.svelte | 83 ++++++++----------- crates/git-same-app/ui/src/lib/Sidebar.svelte | 2 +- 3 files changed, 35 insertions(+), 51 deletions(-) diff --git a/crates/git-same-app/ui/src/App.svelte b/crates/git-same-app/ui/src/App.svelte index ba4479b..005d73e 100644 --- a/crates/git-same-app/ui/src/App.svelte +++ b/crates/git-same-app/ui/src/App.svelte @@ -48,6 +48,7 @@ grid-template-columns: 248px minmax(0, 1fr); min-height: 100vh; color: var(--text); + border-top: 1px solid var(--line); } .content { diff --git a/crates/git-same-app/ui/src/lib/BrandLogo.svelte b/crates/git-same-app/ui/src/lib/BrandLogo.svelte index 8096f1f..9d7bad0 100644 --- a/crates/git-same-app/ui/src/lib/BrandLogo.svelte +++ b/crates/git-same-app/ui/src/lib/BrandLogo.svelte @@ -1,66 +1,49 @@ - @@ -391,7 +413,7 @@ z-index: 1; background: var(--panel); border-bottom: 1px solid var(--line); - padding: 10px 14px 8px; + padding: 7px 14px 6px; color: var(--accent); font-weight: 700; font-size: 12px; @@ -400,7 +422,7 @@ } .row { - min-height: 40px; + min-height: 32px; background: var(--panel); } @@ -451,10 +473,20 @@ .cell.placeholder { color: var(--muted); - font-style: italic; + opacity: 0.55; + justify-content: center; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + } + + /* External-link glyph stays hidden until the row is hovered or focused. */ + .gh-link :global(.gh-ext) { + opacity: 0; + transition: opacity 0.12s ease; + } + + .row:hover .gh-link :global(.gh-ext), + .gh-link:focus-visible :global(.gh-ext) { + opacity: 0.7; } .row.missing a.cell.name { From f42618bb2c1733aa764f03dae0aae53d38c32ff2 Mon Sep 17 00:00:00 2001 From: Manuel Date: Sun, 31 May 2026 19:02:51 +0200 Subject: [PATCH 84/89] Update --- Cargo.lock | 190 ++++++++++++++++++++++++++--------------------------- 1 file changed, 95 insertions(+), 95 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b9193c..697162c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,9 +164,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" @@ -276,9 +276,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -287,9 +287,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -306,9 +306,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -409,9 +409,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -567,9 +567,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -703,7 +703,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", - "mio 1.2.0", + "mio 1.2.1", "parking_lot", "rustix", "signal-hook", @@ -916,9 +916,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1025,9 +1025,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "embed-resource" @@ -1791,9 +1791,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1836,9 +1836,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2268,9 +2268,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2324,9 +2324,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -2411,9 +2411,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -2456,9 +2456,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru" @@ -2507,9 +2507,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmem" @@ -2562,9 +2562,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -2599,9 +2599,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.1" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" dependencies = [ "crossbeam-channel", "dpi", @@ -2701,9 +2701,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -3403,7 +3403,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -3731,9 +3731,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -4113,9 +4113,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4259,9 +4259,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -4280,7 +4280,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 1.2.0", + "mio 1.2.1", "signal-hook", ] @@ -4342,9 +4342,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4556,9 +4556,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.2" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.11.1", "block2 0.6.2", @@ -4613,9 +4613,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" dependencies = [ "anyhow", "bytes", @@ -4664,9 +4664,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" dependencies = [ "anyhow", "cargo_toml", @@ -4685,9 +4685,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" dependencies = [ "base64 0.22.1", "brotli", @@ -4712,9 +4712,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4726,9 +4726,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" dependencies = [ "anyhow", "glob", @@ -4784,9 +4784,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" dependencies = [ "cookie", "dpi", @@ -4809,9 +4809,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", @@ -4835,9 +4835,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" dependencies = [ "anyhow", "brotli", @@ -5083,7 +5083,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio 1.2.0", + "mio 1.2.1", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -5187,7 +5187,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5243,14 +5243,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5259,7 +5259,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5285,9 +5285,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", @@ -5410,9 +5410,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -5553,9 +5553,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "atomic", "getrandom 0.4.2", @@ -5656,9 +5656,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -5669,9 +5669,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -5679,9 +5679,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5689,9 +5689,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -5702,9 +5702,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -5758,9 +5758,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -6477,9 +6477,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -6684,18 +6684,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", From c8f0cdf72ecb657e69c5e01202b73c797741449a Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 11 Jun 2026 23:23:12 +0200 Subject: [PATCH 85/89] Add filter, match-status gutter, and zebra rows to workspace repo table The paired GitHub/local table read as one flat list and did not make it obvious that the repo on the left of a line was the same as the one on the right. Restructure it into a comparison/diff view: - A center status gutter (with a hairline seam) carries a per-row glyph: link for matched, right arrow for missing-locally, left arrow for local-only. The absent side shows a muted ghost label. - Add a filter box in the panel head that narrows both sides in sync by repo name, GitHub full_name, or local folder name. - Zebra striping, whole-row hover, an accent owner band with a repo count, and a paired count in the subtitle improve scan-ability. --- .../ui/src/routes/Workspace.svelte | 206 ++++++++++++++---- 1 file changed, 164 insertions(+), 42 deletions(-) diff --git a/crates/git-same-app/ui/src/routes/Workspace.svelte b/crates/git-same-app/ui/src/routes/Workspace.svelte index 9c70849..a0a2790 100644 --- a/crates/git-same-app/ui/src/routes/Workspace.svelte +++ b/crates/git-same-app/ui/src/routes/Workspace.svelte @@ -1,5 +1,15 @@