From e1226e6ae5a75388fe5d1a0e1ed008f80fee25ed Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 17 May 2026 14:21:25 +0800
Subject: [PATCH] Release Apptag 6.0.5 build 610

---
 Apptag/DataLayer.swift |  491 +++++++++++++++++++++++++++++++++++++++++++-----------
 1 files changed, 387 insertions(+), 104 deletions(-)

diff --git a/Apptag/DataLayer.swift b/Apptag/DataLayer.swift
index ffcd67f..eacfa1d 100644
--- a/Apptag/DataLayer.swift
+++ b/Apptag/DataLayer.swift
@@ -10,11 +10,13 @@
     let tags: [String]
     let bundleIdentifier: String?
     let icon: NSImage  // Pre-loaded during background scan
+    var isUncommon: Bool = false
+    var note: String? = nil
 
     /// True if this is an Apple pre-installed app.
     var isAppleApp: Bool {
         if let bid = bundleIdentifier, bid.hasPrefix("com.apple.") { return true }
-        return path.path.hasPrefix("/System/Applications/")
+        return AppIndexer.isSystemAppPath(path.path)
     }
 
     func hash(into hasher: inout Hasher) { hasher.combine(path) }
@@ -43,7 +45,7 @@
         default: return NSColor.systemGray
         }
     }
-    static let allIndices: [Int] = [0, 1, 2, 3, 4, 5, 6, 7]
+    static let allIndices: [Int] = [1, 2, 3, 4, 5, 6, 7]
 }
 
 // MARK: - App Scanner
@@ -53,40 +55,46 @@
     static let searchPaths: [URL] = [
         URL(fileURLWithPath: "/Applications"),
         URL(fileURLWithPath: "/System/Applications"),
+        URL(fileURLWithPath: "/System/Cryptexes/App/System/Applications"),
+        URL(fileURLWithPath: "/System/Volumes/Preboot/Cryptexes/App/System/Applications"),
         FileManager.default.homeDirectoryForCurrentUser
             .appendingPathComponent("Applications")
     ]
 
-    /// Scan all standard locations. Tags are NOT read from Finder —
-    /// they're annotated from TagDatabase by the caller.
+    private static let systemAppPathPrefixes = [
+        "/System/Applications/",
+        "/System/Cryptexes/App/System/Applications/",
+        "/System/Volumes/Preboot/Cryptexes/App/System/Applications/"
+    ]
+
+    static func isSystemAppPath(_ path: String) -> Bool {
+        systemAppPathPrefixes.contains { path.hasPrefix($0) }
+    }
+
+    /// Scan all standard locations. Tags are annotated from TagDatabase by the caller.
     static func scan() -> [AppInfo] {
-        var seen = Set<URL>()
+        var seenResolvedPaths = Set<String>()
         var apps: [AppInfo] = []
 
         for baseURL in searchPaths {
+            scanDirectChildren(
+                in: baseURL,
+                apps: &apps,
+                seenResolvedPaths: &seenResolvedPaths
+            )
+
             guard let enumerator = FileManager.default.enumerator(
                 at: baseURL,
-                includingPropertiesForKeys: [.isDirectoryKey],
+                includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
                 options: [.skipsHiddenFiles, .skipsPackageDescendants]
             ) else { continue }
 
             for case let url as URL in enumerator {
-                guard url.pathExtension == "app" else { continue }
-                guard !seen.contains(url) else { continue }
-                seen.insert(url)
-
-                let name = url.deletingPathExtension().lastPathComponent
-                let icon = NSWorkspace.shared.icon(forFile: url.path)
-                icon.size = NSSize(width: 96, height: 96)
-                var bundleId: String? = nil
-                if let bundle = Bundle(url: url) {
-                    bundleId = bundle.bundleIdentifier
-                }
-
-                apps.append(AppInfo(
-                    name: name, path: url, tags: [],
-                    bundleIdentifier: bundleId, icon: icon
-                ))
+                appendAppIfNeeded(
+                    at: url,
+                    apps: &apps,
+                    seenResolvedPaths: &seenResolvedPaths
+                )
             }
         }
 
@@ -95,11 +103,56 @@
         }
     }
 
+    private static func scanDirectChildren(
+        in baseURL: URL,
+        apps: inout [AppInfo],
+        seenResolvedPaths: inout Set<String>
+    ) {
+        guard let names = try? FileManager.default.contentsOfDirectory(atPath: baseURL.path) else { return }
+
+        for name in names where !name.hasPrefix(".") {
+            appendAppIfNeeded(
+                at: baseURL.appendingPathComponent(name),
+                apps: &apps,
+                seenResolvedPaths: &seenResolvedPaths
+            )
+        }
+    }
+
+    private static func appendAppIfNeeded(
+        at url: URL,
+        apps: inout [AppInfo],
+        seenResolvedPaths: inout Set<String>
+    ) {
+        guard url.pathExtension.lowercased() == "app" else { return }
+
+        let displayURL = url.standardizedFileURL
+        let resolvedURL = displayURL.resolvingSymlinksInPath().standardizedFileURL
+        guard seenResolvedPaths.insert(resolvedURL.path).inserted else { return }
+
+        let name = displayURL.deletingPathExtension().lastPathComponent
+        let icon = NSWorkspace.shared.icon(forFile: displayURL.path)
+        icon.size = NSSize(width: 96, height: 96)
+
+        let bundleId = Bundle(url: displayURL)?.bundleIdentifier
+            ?? Bundle(url: resolvedURL)?.bundleIdentifier
+
+        apps.append(AppInfo(
+            name: name,
+            path: displayURL,
+            tags: [],
+            bundleIdentifier: bundleId,
+            icon: icon
+        ))
+    }
+
     /// Group apps by their tags (from TagDatabase).
+    /// Groups are sorted by tagOrder (user-defined), falling back to alpha.
     static func group(
         apps: [AppInfo],
         nameOverrides: [String: String] = [:],
-        defaultGroupName: String = "Other"
+        defaultGroupName: String = "Other",
+        tagOrder: [String] = []
     ) -> [TagGroup] {
         let macCategory = "Mac自带"
         var dict: [String: [AppInfo]] = [:]
@@ -116,21 +169,37 @@
             }
         }
 
+        // Build sort index from tagOrder: lower index = appears first
+        var orderIndex: [String: Int] = [:]
+        for (i, name) in tagOrder.enumerated() {
+            orderIndex[name] = i
+        }
+
         return dict
             .sorted { lhs, rhs in
                 if lhs.key == macCategory { return false }
                 if rhs.key == macCategory { return true }
                 if lhs.key == defaultGroupName { return false }
                 if rhs.key == defaultGroupName { return true }
+                // Use custom order if both are in tagOrder
+                let li = orderIndex[lhs.key] ?? Int.max
+                let ri = orderIndex[rhs.key] ?? Int.max
+                if li != ri { return li < ri }
                 return lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending
             }
             .map { TagGroup(name: $0.key, apps: $0.value) }
     }
 }
 
-// MARK: - Local Tag Database (JSON-backed)
-
 enum TagDatabase {
+    static let uncommonTagKey = "__system.uncommon"
+    static let maxAppNoteLength = 80
+    static let autoUncommonOpenThreshold = 100
+
+    enum UncommonSource: String, Codable {
+        case auto
+        case manual
+    }
 
     // MARK: Storage types
 
@@ -142,7 +211,43 @@
         var version: Int = 1
         var tags: [String: TagDef] = [:]
         var appTags: [String: [String]] = [:]  // path → tag names
-        var migrated: Bool = false
+        var tagOrder: [String] = []  // display order; empty → alpha sort
+        var uncommonAppPaths: [String] = []  // special marker; does not affect normal groups
+        var uncommonSources: [String: UncommonSource] = [:]  // current uncommon source: auto/manual
+        var appOpenCounts: [String: Int] = [:]  // launches opened from TagLauncher
+        var knownAppPaths: [String] = []  // baseline set to detect newly installed apps
+        var appNotes: [String: String] = [:]  // path → user note; retained even if marker is removed
+
+        enum CodingKeys: String, CodingKey {
+            case version
+            case tags
+            case appTags
+            case tagOrder
+            case uncommonAppPaths
+            case uncommonSources
+            case appOpenCounts
+            case knownAppPaths
+            case appNotes
+        }
+
+        init() {}
+
+        init(from decoder: Decoder) throws {
+            let container = try decoder.container(keyedBy: CodingKeys.self)
+            version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
+            tags = try container.decodeIfPresent([String: TagDef].self, forKey: .tags) ?? [:]
+            appTags = try container.decodeIfPresent([String: [String]].self, forKey: .appTags) ?? [:]
+            tagOrder = try container.decodeIfPresent([String].self, forKey: .tagOrder) ?? []
+            uncommonAppPaths = try container.decodeIfPresent([String].self, forKey: .uncommonAppPaths) ?? []
+            uncommonSources = try container.decodeIfPresent([String: UncommonSource].self, forKey: .uncommonSources) ?? [:]
+            appOpenCounts = try container.decodeIfPresent([String: Int].self, forKey: .appOpenCounts) ?? [:]
+            knownAppPaths = try container.decodeIfPresent([String].self, forKey: .knownAppPaths) ?? []
+            appNotes = try container.decodeIfPresent([String: String].self, forKey: .appNotes) ?? [:]
+
+            for path in uncommonAppPaths where uncommonSources[path] == nil {
+                uncommonSources[path] = .manual
+            }
+        }
     }
 
     // MARK: Paths
@@ -156,9 +261,25 @@
 
     static var storeURL: URL { storeDir.appendingPathComponent("tags.json") }
 
+    private static var legacyStoreURL: URL? {
+        guard ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil else {
+            return nil
+        }
+
+        let home = FileManager.default.homeDirectoryForCurrentUser
+        let bundleID = Bundle.main.bundleIdentifier ?? "com.apptag.launcher"
+        let marker = "/Library/Containers/\(bundleID)/Data"
+        guard let range = home.path.range(of: marker) else { return nil }
+
+        let realHomePath = String(home.path[..<range.lowerBound])
+        return URL(fileURLWithPath: realHomePath)
+            .appendingPathComponent("Library/Application Support/Apptag/tags.json")
+    }
+
     // MARK: Load / Save
 
     static func load() -> Store {
+        migrateLegacyStoreIfNeeded()
         guard let data = try? Data(contentsOf: storeURL),
               let store = try? JSONDecoder().decode(Store.self, from: data)
         else { return Store() }
@@ -166,91 +287,46 @@
     }
 
     static func save(_ store: Store) {
-        guard let data = try? JSONEncoder().encode(store) else { return }
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        guard let data = try? encoder.encode(store) else { return }
         try? data.write(to: storeURL, options: .atomic)
     }
 
-    // MARK: Migration (first launch: import Finder tags)
+    /// App Store sandbox builds read Application Support inside the app container.
+    /// Older non-sandbox builds stored the same database directly under ~/Library.
+    /// On first sandbox launch, migrate the richer legacy database before seeding defaults.
+    static func migrateLegacyStoreIfNeeded() {
+        let fm = FileManager.default
+        guard let legacyURL = legacyStoreURL,
+              fm.fileExists(atPath: legacyURL.path)
+        else { return }
 
-    /// Run once on first launch. Reads Finder tags from all scanned apps
-    /// and seeds the local database. Also auto-assigns "Mac自带" to Apple apps.
-    static func migrateFromFinderIfNeeded(apps: [AppInfo]) -> Store {
-        var store = load()
-        guard !store.migrated else { return store }
+        guard let legacyData = try? Data(contentsOf: legacyURL),
+              let legacyStore = try? JSONDecoder().decode(Store.self, from: legacyData)
+        else { return }
 
-        // Read Finder tags from each app
-        for app in apps {
-            let finderTags = readFinderTags(from: app.path)
-            guard !finderTags.isEmpty else { continue }
-            store.appTags[app.path.path] = finderTags.map { $0.name }
-            for (name, color) in finderTags {
-                if store.tags[name] == nil {
-                    store.tags[name] = TagDef(color: color)
-                }
-            }
+        if let currentData = try? Data(contentsOf: storeURL),
+           let currentStore = try? JSONDecoder().decode(Store.self, from: currentData),
+           storeScore(currentStore) >= storeScore(legacyStore) {
+            return
         }
 
-        // Auto-assign "Mac自带" tag to Apple apps
-        for app in apps where app.isAppleApp {
-            var current = store.appTags[app.path.path] ?? []
-            if !current.contains("Mac自带") {
-                current.append("Mac自带")
-                store.appTags[app.path.path] = current
-            }
-        }
-        if store.tags["Mac自带"] == nil {
-            store.tags["Mac自带"] = TagDef(color: 1)  // gray
-        }
-
-        store.migrated = true
-        save(store)
-        return store
+        try? fm.createDirectory(at: storeDir, withIntermediateDirectories: true)
+        try? legacyData.write(to: storeURL, options: .atomic)
     }
 
-    /// Read Finder tags (name, color) from a single .app bundle.
-    /// Only used during one-time migration.
-    private static func readFinderTags(from url: URL) -> [(name: String, color: Int)] {
-        // Read tag names via NSURL resource values
-        guard let values = try? url.resourceValues(forKeys: [.tagNamesKey]),
-              let tagNames = values.tagNames, !tagNames.isEmpty
-        else { return [] }
-
-        // Read tag colors from xattr
-        let xattrName = "com.apple.metadata:_kMDItemUserTags"
-        let path = url.path
-        let size = getxattr(path, xattrName, nil, 0, 0, 0)
-        guard size > 0 else {
-            return tagNames.map { ($0, 0) }
-        }
-
-        var buffer = [UInt8](repeating: 0, count: size)
-        guard getxattr(path, xattrName, &buffer, size, 0, 0) == size else {
-            return tagNames.map { ($0, 0) }
-        }
-
-        let data = Data(buffer)
-        guard let plist = try? PropertyListSerialization.propertyList(
-            from: data, options: [], format: nil
-        ) as? [String] else {
-            return tagNames.map { ($0, 0) }
-        }
-
-        // Build name→color map from xattr entries
-        var colorMap: [String: Int] = [:]
-        for entry in plist {
-            let parts = entry.components(separatedBy: "\n")
-            guard !parts[0].isEmpty else { continue }
-            let c = (parts.count >= 2) ? (Int(parts[1]) ?? 0) : 0
-            colorMap[parts[0]] = (0...7).contains(c) ? c : 0
-        }
-
-        return tagNames.map { ($0, colorMap[$0] ?? 0) }
+    private static func storeScore(_ store: Store) -> Int {
+        store.appTags.count * 100 + store.tagOrder.count * 10 + store.tags.count
     }
 
     // MARK: Export / Import
 
     static func exportTo(_ url: URL) throws {
-        try FileManager.default.copyItem(at: storeURL, to: url)
+        migrateLegacyStoreIfNeeded()
+        let store = load()
+        let data = try JSONEncoder().encode(store)
+        try data.write(to: url, options: .atomic)
     }
 
     static func importFrom(_ url: URL) throws -> Store {
@@ -258,6 +334,28 @@
         let store = try JSONDecoder().decode(Store.self, from: data)
         save(store)
         return store
+    }
+
+    /// Seed default tags on first launch. Only runs if store doesn't exist yet.
+    /// Tag names are loaded from the current language's localization.
+    static func seedDefaultTags() {
+        migrateLegacyStoreIfNeeded()
+        guard !FileManager.default.fileExists(atPath: storeURL.path) else { return }
+
+        let keys = [
+            "tag.design", "tag.development", "tag.writing",
+            "tag.gaming", "tag.entertainment", "tag.system",
+            "tag.productivity"
+        ]
+        let colors: [Int] = [1, 2, 3, 4, 5, 6, 7]
+
+        var store = Store()
+        for (i, key) in keys.enumerated() {
+            let name = tr(key)
+            store.tags[name] = TagDef(color: colors[i])
+            store.tagOrder.append(name)
+        }
+        save(store)
     }
 }
 
@@ -273,14 +371,44 @@
         return store.tags.mapValues { $0.color }
     }
 
+    /// Tag names in display order. Falls back to alpha sort if no custom order.
+    static func orderedTagNames() -> [String] {
+        let store = TagDatabase.load()
+        let ordered = store.tagOrder.filter { store.tags[$0] != nil }
+        let remaining = store.tags.keys.filter { !ordered.contains($0) }.sorted()
+        return ordered + remaining
+    }
+
+    /// Persist a new tag display order.
+    static func reorderTags(_ names: [String]) {
+        var store = TagDatabase.load()
+        store.tagOrder = names
+        TagDatabase.save(store)
+    }
+
+    /// Create a new tag definition (no app assignments yet).
+    static func createTag(_ name: String, color: Int) {
+        var store = TagDatabase.load()
+        guard store.tags[name] == nil else { return }
+        store.tags[name] = TagDatabase.TagDef(color: color)
+        if !store.tagOrder.contains(name) { store.tagOrder.insert(name, at: 0) }
+        TagDatabase.save(store)
+    }
+
     /// Annotate scanned apps with tags from the database.
     static func annotate(apps: [AppInfo]) -> [AppInfo] {
-        let store = TagDatabase.load()
+        annotate(apps: apps, store: TagDatabase.load())
+    }
+
+    static func annotate(apps: [AppInfo], store: TagDatabase.Store) -> [AppInfo] {
+        let uncommonPaths = Set(store.uncommonAppPaths)
         return apps.map { app in
             let appTags = store.appTags[app.path.path] ?? []
             return AppInfo(
                 name: app.name, path: app.path, tags: appTags,
-                bundleIdentifier: app.bundleIdentifier, icon: app.icon
+                bundleIdentifier: app.bundleIdentifier, icon: app.icon,
+                isUncommon: uncommonPaths.contains(app.path.path),
+                note: store.appNotes[app.path.path]
             )
         }
     }
@@ -291,7 +419,11 @@
     static func assignTag(_ tag: String, color: Int, to paths: [String]) {
         var store = TagDatabase.load()
         // Ensure tag definition exists
-        if store.tags[tag] == nil { store.tags[tag] = TagDatabase.TagDef(color: color) }
+        if store.tags[tag] == nil {
+            store.tags[tag] = TagDatabase.TagDef(color: color)
+            // New tag: append to display order
+            if !store.tagOrder.contains(tag) { store.tagOrder.insert(tag, at: 0) }
+        }
         for path in paths {
             var current = store.appTags[path] ?? []
             if !current.contains(tag) {
@@ -302,14 +434,164 @@
         TagDatabase.save(store)
     }
 
-    /// Remove a tag from specific apps.
-    static func removeTag(_ tag: String, from paths: [String]) {
+    /// Replace the full editable tag set for multiple apps.
+    static func setTags(_ tags: [String], to paths: [String]) {
         var store = TagDatabase.load()
+        let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
+        let validTags = tags.filter { store.tags[$0] != nil }
+        var uncommonPaths = Set(store.uncommonAppPaths)
         for path in paths {
-            store.appTags[path]?.removeAll { $0 == tag }
-            if store.appTags[path]?.isEmpty == true {
+            if validTags.isEmpty {
                 store.appTags.removeValue(forKey: path)
+            } else {
+                store.appTags[path] = validTags
             }
+            if selectedUncommon {
+                uncommonPaths.insert(path)
+                store.uncommonSources[path] = .manual
+            } else {
+                uncommonPaths.remove(path)
+                store.uncommonSources.removeValue(forKey: path)
+            }
+        }
+        store.uncommonAppPaths = uncommonPaths.sorted()
+        TagDatabase.save(store)
+    }
+
+    /// Append tags to multiple apps without disturbing their existing tag sets.
+    static func appendTags(_ tags: [String], to paths: [String]) {
+        guard !tags.isEmpty, !paths.isEmpty else { return }
+
+        var store = TagDatabase.load()
+        let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
+        let validTags = tags.filter { store.tags[$0] != nil }
+        var uncommonPaths = Set(store.uncommonAppPaths)
+
+        for path in paths {
+            var current = store.appTags[path] ?? []
+            for tag in validTags where !current.contains(tag) {
+                current.append(tag)
+            }
+            if !current.isEmpty {
+                store.appTags[path] = current
+            }
+
+            if selectedUncommon {
+                uncommonPaths.insert(path)
+                store.uncommonSources[path] = .manual
+            }
+        }
+
+        store.uncommonAppPaths = uncommonPaths.sorted()
+        TagDatabase.save(store)
+    }
+
+    /// Remove tags from multiple apps while preserving every unrelated tag.
+    static func removeTags(_ tags: [String], from paths: [String]) {
+        guard !tags.isEmpty, !paths.isEmpty else { return }
+
+        var store = TagDatabase.load()
+        let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
+        let validTags = Set(tags.filter { store.tags[$0] != nil })
+        var uncommonPaths = Set(store.uncommonAppPaths)
+
+        for path in paths {
+            var current = store.appTags[path] ?? []
+            current.removeAll { validTags.contains($0) }
+
+            if current.isEmpty {
+                store.appTags.removeValue(forKey: path)
+            } else {
+                store.appTags[path] = current
+            }
+
+            if selectedUncommon {
+                uncommonPaths.remove(path)
+                store.uncommonSources.removeValue(forKey: path)
+            }
+        }
+
+        store.uncommonAppPaths = uncommonPaths.sorted()
+        TagDatabase.save(store)
+    }
+
+    static func reconcileScannedApps(_ apps: [AppInfo]) -> TagDatabase.Store {
+        var store = TagDatabase.load()
+        let scannedPaths = Set(apps.map { $0.path.path })
+        let knownPaths = Set(store.knownAppPaths)
+
+        if store.knownAppPaths.isEmpty {
+            store.knownAppPaths = scannedPaths.sorted()
+            if apps.isEmpty == false {
+                TagDatabase.save(store)
+            }
+            return store
+        }
+
+        let newPaths = scannedPaths.subtracting(knownPaths)
+        guard !newPaths.isEmpty else { return store }
+
+        var uncommonPaths = Set(store.uncommonAppPaths)
+        for path in newPaths {
+            uncommonPaths.insert(path)
+            store.uncommonSources[path] = .auto
+            if store.appOpenCounts[path] == nil {
+                store.appOpenCounts[path] = 0
+            }
+        }
+
+        store.uncommonAppPaths = uncommonPaths.sorted()
+        store.knownAppPaths = knownPaths.union(newPaths).sorted()
+        TagDatabase.save(store)
+        return store
+    }
+
+    static func recordLauncherOpen(for path: String) {
+        var store = TagDatabase.load()
+        store.appOpenCounts[path] = (store.appOpenCounts[path] ?? 0) + 1
+
+        if store.uncommonSources[path] == .auto,
+           store.appOpenCounts[path, default: 0] >= TagDatabase.autoUncommonOpenThreshold {
+            var uncommonPaths = Set(store.uncommonAppPaths)
+            uncommonPaths.remove(path)
+            store.uncommonAppPaths = uncommonPaths.sorted()
+            store.uncommonSources.removeValue(forKey: path)
+        }
+
+        TagDatabase.save(store)
+    }
+
+    static func setAppNote(_ note: String, for path: String) {
+        var store = TagDatabase.load()
+        let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
+        let limited = String(trimmed.prefix(TagDatabase.maxAppNoteLength))
+        if limited.isEmpty {
+            store.appNotes.removeValue(forKey: path)
+        } else {
+            store.appNotes[path] = limited
+        }
+        TagDatabase.save(store)
+    }
+
+    static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) {
+        var store = TagDatabase.load()
+        if store.tags[targetTag] == nil {
+            store.tags[targetTag] = TagDatabase.TagDef(color: color)
+            if !store.tagOrder.contains(targetTag) { store.tagOrder.insert(targetTag, at: 0) }
+        }
+
+        var current = store.appTags[path] ?? []
+        if !copy, !sourceTag.isEmpty {
+            current.removeAll { $0 == sourceTag }
+        }
+        if !current.contains(targetTag) {
+            current.append(targetTag)
+        }
+
+        if current.isEmpty {
+            store.appTags.removeValue(forKey: path)
+        } else {
+            store.appTags[path] = current
         }
         TagDatabase.save(store)
     }
@@ -335,6 +617,7 @@
     static func deleteTagCompletely(_ tag: String) {
         var store = TagDatabase.load()
         store.tags.removeValue(forKey: tag)
+        store.tagOrder.removeAll { $0 == tag }
         for (path, var tags) in store.appTags {
             tags.removeAll { $0 == tag }
             if tags.isEmpty {

--
Gitblit v1.9.3