Files
web-terminal/ios/App/WebTerm/ViewModels/ProjectGrouping.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00

193 lines
8.1 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import APIClient
import Foundation
/// T-iOS-26 · Projects web v0.6
/// public/projects.tsfilterProjects / sortProjects / groupProjects /
/// displayLabel使
///
/// ** key web **namespace
/// `" active"` / `" other"` key
/// `/prefs.collapsed` key =
/// label UI namespace = key
///
/// Active assert reality`ProjectSessionRef.exited`
/// src/types.ts:262-269running = `!exited` web
/// `hasRunningSession` running ****
enum ProjectGrouping {
/// key `First.Second` namespace
/// public/projects.ts:83-84
static let activeGroupKey = " active"
static let otherGroupKey = " other"
/// namespace MIN_GROUP_SIZE
static let minGroupSize = 2
// MARK: - filter filterProjectsname/path
static func filter(_ projects: [ProjectInfo], query: String) -> [ProjectInfo] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return projects }
let lower = trimmed.lowercased()
return projects.filter {
$0.name.lowercased().contains(lower) || $0.path.lowercased().contains(lower)
}
}
// MARK: - sort sortProjects lastActiveMs
/// JS `Array.sort` web Swift
/// `sorted`
static func sort(_ projects: [ProjectInfo], favourites: Set<String>) -> [ProjectInfo] {
projects.enumerated().sorted { a, b in
let aFav = favourites.contains(a.element.path)
let bFav = favourites.contains(b.element.path)
if aFav != bFav { return aFav }
let aActive = a.element.lastActiveMs ?? 0
let bActive = b.element.lastActiveMs ?? 0
if aActive != bActive { return aActive > bActive }
return a.offset < b.offset
}.map(\.element)
}
// MARK: - group groupProjects
static func group(_ projects: [ProjectInfo], favourites: Set<String>) -> [ProjectGroup] {
let (namespaceGroups, other) = bucketByNamespace(projects, favourites: favourites)
// flat chrome 退
guard !namespaceGroups.isEmpty else {
return [makeGroup(
key: otherGroupKey, label: ProjectsCopy.allGroupLabel,
kind: .flat, items: projects, favourites: favourites
)]
}
var groups: [ProjectGroup] = []
let active = projects.filter(hasRunningSession)
if !active.isEmpty {
groups.append(makeGroup(
key: activeGroupKey, label: ProjectsCopy.activeGroupLabel,
kind: .active, items: active, favourites: favourites
))
}
groups.append(contentsOf: orderedByRecency(namespaceGroups))
if !other.isEmpty {
groups.append(makeGroup(
key: otherGroupKey, label: ProjectsCopy.otherGroupLabel,
kind: .other, items: other, favourites: favourites
))
}
return groups
}
/// namespace `<key>.`
/// `Billo.Platform.` displayLabel
static func displayLabel(name: String, groupKey: String) -> String {
if groupKey == activeGroupKey || groupKey == otherGroupKey { return name }
let prefix = "\(groupKey)."
guard name.lowercased().hasPrefix(prefix.lowercased()) else { return name }
return String(name.dropFirst(prefix.count))
}
static func hasRunningSession(_ project: ProjectInfo) -> Bool {
project.sessions.contains { !$0.exited }
}
// MARK: - Internals
/// namespace = `'a.b.c'` `'a.b'` nil
/// JS `split('.')`
private static func namespaceKey(_ name: String) -> String? {
let segments = name.split(separator: ".", omittingEmptySubsequences: false)
guard segments.count >= 2 else { return nil }
return segments.prefix(2).joined(separator: ".")
}
/// key JS Map
/// ( namespace, Other )
private static func bucketByNamespace(
_ projects: [ProjectInfo], favourites: Set<String>
) -> (groups: [ProjectGroup], other: [ProjectInfo]) {
var bucketOrder: [String] = []
var buckets: [String: (display: String, items: [ProjectInfo])] = [:]
var other: [ProjectInfo] = []
for project in projects {
guard let namespace = namespaceKey(project.name) else {
other.append(project)
continue
}
let lowerKey = namespace.lowercased()
if var bucket = buckets[lowerKey] {
bucket.items.append(project)
buckets[lowerKey] = bucket
} else {
buckets[lowerKey] = (namespace, [project])
bucketOrder.append(lowerKey)
}
}
var groups: [ProjectGroup] = []
for lowerKey in bucketOrder {
guard let bucket = buckets[lowerKey] else { continue }
if bucket.items.count < minGroupSize {
other.append(contentsOf: bucket.items)
} else {
groups.append(makeGroup(
key: bucket.display, label: bucket.display,
kind: .namespace, items: bucket.items, favourites: favourites
))
}
}
return (groups, other)
}
/// namespace label
/// groupProjects sort
private static func orderedByRecency(_ groups: [ProjectGroup]) -> [ProjectGroup] {
groups.sorted { a, b in
let aMax = maxLastActive(a.projects)
let bMax = maxLastActive(b.projects)
if aMax != bMax { return aMax > bMax }
return a.label.localizedCompare(b.label) == .orderedAscending
}
}
private static func maxLastActive(_ items: [ProjectInfo]) -> Int {
items.reduce(0) { max($0, $1.lastActiveMs ?? 0) }
}
private static func makeGroup(
key: String, label: String, kind: ProjectGroupKind,
items: [ProjectInfo], favourites: Set<String>
) -> ProjectGroup {
ProjectGroup(
key: key, label: label, kind: kind,
projects: sort(items, favourites: favourites),
activeCount: items.filter(hasRunningSession).count
)
}
}
/// web `ProjectGroupKind`
enum ProjectGroupKind: Equatable, Sendable {
case active
case namespace
case other
case flat
}
/// web `ProjectGroup`
struct ProjectGroup: Equatable, Identifiable, Sendable {
/// key web /prefs
let key: String
let label: String
let kind: ProjectGroupKind
///
let projects: [ProjectInfo]
///
let activeCount: Int
var id: String { key }
/// Active now flat chrome
var isCollapsible: Bool { kind == .namespace || kind == .other }
}