diff --git a/ios/MarkupApp/MarkupApp/GitHubService.swift b/ios/MarkupApp/MarkupApp/GitHubService.swift index 8af9811..b4ebe87 100644 --- a/ios/MarkupApp/MarkupApp/GitHubService.swift +++ b/ios/MarkupApp/MarkupApp/GitHubService.swift @@ -272,6 +272,57 @@ final class GitHubService { return url } + /// The base directory holding all downloaded GitHub vaults. + nonisolated static var vaultsBase: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("GitHubVaults", isDirectory: true) + } + + /// All materialized GitHub vault directories on disk (those carrying a + /// `.markup` manifest sidecar), with their byte sizes. + nonisolated static func downloadedVaults() -> [(url: URL, bytes: Int64)] { + let fm = FileManager.default + guard let en = fm.enumerator(at: vaultsBase, includingPropertiesForKeys: [.isDirectoryKey]) + else { return [] } + var out: [(URL, Int64)] = [] + for case let url as URL in en { + let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + if isDir, readMeta(vaultRoot: url) != nil { + out.append((url, directorySize(url))) + en.skipDescendants() // don't recurse into a vault we already counted + } + } + return out + } + + /// Delete every downloaded GitHub vault except `keeping` (the active one), + /// plus the transient asset cache. Returns the number of vaults removed. + /// Safe: never touches the open vault or anything outside our containers. + @discardableResult + nonisolated static func clearCaches(keeping active: URL?) -> Int { + let fm = FileManager.default + try? fm.removeItem(at: cacheRoot) // transient per-doc asset cache + let keepPath = active?.standardizedFileURL.path + var removed = 0 + for vault in downloadedVaults() where vault.url.standardizedFileURL.path != keepPath { + if (try? fm.removeItem(at: vault.url)) != nil { removed += 1 } + } + return removed + } + + /// Recursive byte size of a directory (best-effort; 0 on error). + nonisolated static func directorySize(_ url: URL) -> Int64 { + let fm = FileManager.default + guard let en = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) else { + return 0 + } + var total: Int64 = 0 + for case let f as URL in en { + total += Int64((try? f.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0) + } + return total + } + /// Extract a GitHub zipball (one `owner-repo-sha/` wrapper dir) into `root`, /// stripping the wrapper so paths are repo-root-relative. Builds the tree in /// a sibling temp dir and **atomically** swaps it into place, so a re-open of diff --git a/ios/MarkupApp/MarkupApp/Localization.swift b/ios/MarkupApp/MarkupApp/Localization.swift index 53ea77b..78686ca 100644 --- a/ios/MarkupApp/MarkupApp/Localization.swift +++ b/ios/MarkupApp/MarkupApp/Localization.swift @@ -69,6 +69,7 @@ enum L { case refreshFromGitHub, refreshing, refreshUpToDate, refreshUpdatedSuffix, refreshedFull case ghPreparing, ghDownloading, ghExtracting case refreshOverwriteTitle, refreshOverwriteBody, refreshOverwrite + case githubDownloaded, clearGithubCache var en: String { switch self { @@ -185,6 +186,8 @@ enum L { case .refreshOverwriteBody: return "%d file(s) have changes you made since the last sync. Refreshing from GitHub will overwrite them." case .refreshOverwrite: return "Refresh & Overwrite" + case .githubDownloaded: return "Downloaded repos" + case .clearGithubCache: return "Clear GitHub cache" case .refreshing: return "Refreshing…" case .refreshUpToDate: return "Already up to date" case .refreshUpdatedSuffix: return "updated" @@ -303,6 +306,8 @@ enum L { case .refreshOverwriteBody: return "有 %d 个文件在上次同步后被你修改过。从 GitHub 刷新会覆盖这些改动。" case .refreshOverwrite: return "刷新并覆盖" + case .githubDownloaded: return "已下载仓库" + case .clearGithubCache: return "清理 GitHub 缓存" case .refreshing: return "刷新中…" case .refreshUpToDate: return "已是最新" case .refreshUpdatedSuffix: return "项已更新" diff --git a/ios/MarkupApp/MarkupApp/RootView.swift b/ios/MarkupApp/MarkupApp/RootView.swift index 9cbedbd..d786546 100644 --- a/ios/MarkupApp/MarkupApp/RootView.swift +++ b/ios/MarkupApp/MarkupApp/RootView.swift @@ -88,6 +88,14 @@ struct RootView: View { guard openingVault == nil else { return } showGitHub = false githubBrowse = nil + // Already downloaded? Open the local copy instantly instead of + // re-downloading the whole repo — "Refresh from GitHub" pulls the latest. + // Mirrors the desktop, which also reuses a materialized vault. + let existing = GitHubService.vaultRoot(for: link) + if GitHubService.readMeta(vaultRoot: existing) != nil { + vault.openLocalVault(existing) + return + } openingVault = "\(link.owner)/\(link.repo)" openingStatus = nil Task { diff --git a/ios/MarkupApp/MarkupApp/SettingsView.swift b/ios/MarkupApp/MarkupApp/SettingsView.swift index e070fa4..d4d3e5c 100644 --- a/ios/MarkupApp/MarkupApp/SettingsView.swift +++ b/ios/MarkupApp/MarkupApp/SettingsView.swift @@ -11,6 +11,14 @@ struct SettingsView: View { @AppStorage("reader.fontScale") private var fontScale = 1.0 @AppStorage("reader.maxWidth") private var maxWidth = 720 @AppStorage("reader.lineHeight") private var lineHeight = 1.65 + /// Total bytes used by downloaded GitHub vaults (computed off-main). + @State private var cacheBytes: Int64 = 0 + + private func refreshCacheSize() async { + cacheBytes = await Task.detached(priority: .utility) { + GitHubService.downloadedVaults().reduce(0) { $0 + $1.bytes } + }.value + } private var appVersion: String { let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" @@ -60,6 +68,24 @@ struct SettingsView: View { Button(t(.reindex)) { vault.scan() } } + if cacheBytes > 0 { + Section("GitHub") { + LabeledContent( + t(.githubDownloaded), + value: ByteCountFormatter.string(fromByteCount: cacheBytes, countStyle: .file)) + Button(t(.clearGithubCache), role: .destructive) { + Task { + let active = vault.rootURL + await Task.detached(priority: .utility) { + GitHubService.clearCaches(keeping: active) + }.value + vault.forgetMissingVaults() + await refreshCacheSize() + } + } + } + } + Section(t(.about)) { LabeledContent(t(.version), value: appVersion) Link(t(.onGitHub), destination: URL(string: "https://github.com/oratis/Markup")!) @@ -70,6 +96,7 @@ struct SettingsView: View { } .navigationTitle(t(.settings)) .navigationBarTitleDisplayMode(.inline) + .task { await refreshCacheSize() } .toolbar { ToolbarItem(placement: .cancellationAction) { Button(t(.done)) { dismiss() } } } } } diff --git a/ios/MarkupApp/MarkupApp/VaultStore.swift b/ios/MarkupApp/MarkupApp/VaultStore.swift index 160268d..d45e25d 100644 --- a/ios/MarkupApp/MarkupApp/VaultStore.swift +++ b/ios/MarkupApp/MarkupApp/VaultStore.swift @@ -134,6 +134,14 @@ final class VaultStore { saveKnownVaults() } + /// Drop remembered vaults whose folder no longer exists on disk (e.g. after + /// clearing the GitHub cache), so the switcher doesn't list dead entries. + func forgetMissingVaults() { + let fm = FileManager.default + knownVaults.removeAll { !fm.fileExists(atPath: $0.path) } + saveKnownVaults() + } + var rootName: String { rootURL?.lastPathComponent ?? "No folder" } /// A readable, `/`-joined path for the open vault, cleaning the iCloud