Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 378 additions & 0 deletions apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
import CoreGraphics
import Foundation

enum FeatureCommandPaletteAction: Hashable {
case newTask(projectID: String?)
case chooseNewTaskProject
case addProject
case settings
case openThread(id: String)
case openProject(id: String)
}

struct FeatureCommandPaletteItem: Identifiable, Equatable {
let id: String
let title: String
let detail: String?
let searchTerms: [String]
let systemImage: String
let action: FeatureCommandPaletteAction
let showsDisclosure: Bool

init(
id: String,
title: String,
detail: String? = nil,
searchTerms: [String] = [],
systemImage: String,
action: FeatureCommandPaletteAction,
showsDisclosure: Bool = false
) {
self.id = id
self.title = title
self.detail = detail
self.searchTerms = searchTerms
self.systemImage = systemImage
self.action = action
self.showsDisclosure = showsDisclosure
}
}

struct FeatureCommandPaletteGroup: Identifiable, Equatable {
let id: String
let title: String
let items: [FeatureCommandPaletteItem]
}

enum FeatureCommandPaletteMode: Equatable {
case root
case newTaskProjectPicker
}

enum FeatureCommandPaletteGesture {
static let maximumStartY: CGFloat = 88
static let minimumDownwardTranslation: CGFloat = 72

static func shouldPresent(startY: CGFloat, translation: CGSize) -> Bool {
guard startY <= maximumStartY,
translation.height >= minimumDownwardTranslation else {
return false
}

return translation.height > abs(translation.width) * 1.15
}
}

enum FeatureCommandPaletteCatalog {
static let recentThreadLimit = 12

static func groups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
activeProjectID: String?,
query: String
) -> [FeatureCommandPaletteGroup] {
let actions = actionItems(projects: projects, activeProjectID: activeProjectID)
let rawQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let actionsOnly = rawQuery.first == ">"
let searchQuery = actionsOnly
? String(rawQuery.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
: rawQuery

if searchQuery.isEmpty {
if actionsOnly {
return group(id: "actions", title: "Actions", items: actions)
}

return rootGroups(
actions: actions,
recentThreads: recentThreadItems(snapshot: snapshot)
)
}

let filteredActions = matchingItems(actions, query: searchQuery)
var groups: [FeatureCommandPaletteGroup] = []
if !filteredActions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: filteredActions
))
}

guard !actionsOnly else { return groups }

let filteredProjects = matchingItems(
projectItems(snapshot: snapshot, projects: projects),
query: searchQuery
)
if !filteredProjects.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "projects-search",
title: "Projects",
items: filteredProjects
))
}

let filteredThreads = matchingItems(
threadItems(snapshot: snapshot),
query: searchQuery
)
if !filteredThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "threads-search",
title: "Threads",
items: filteredThreads
))
}

return groups
}

static func newTaskProjectGroups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
query: String
) -> [FeatureCommandPaletteGroup] {
let items = matchingItems(
projectItems(
snapshot: snapshot,
projects: projects,
action: { .newTask(projectID: $0.id) },
idPrefix: "new-task-in"
),
query: query
)
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: "projects", title: "Projects", items: items)]
}

private static func rootGroups(
actions: [FeatureCommandPaletteItem],
recentThreads: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
var groups: [FeatureCommandPaletteGroup] = []
if !actions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: actions
))
}
if !recentThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "recent-threads",
title: "Recent Threads",
items: recentThreads
))
}
return groups
}

private static func actionItems(
projects: [FeatureProject],
activeProjectID: String?
) -> [FeatureCommandPaletteItem] {
var items: [FeatureCommandPaletteItem] = []
if !projects.isEmpty {
let activeProject = projects.first { $0.id == activeProjectID } ?? projects.first
if let activeProject {
items.append(FeatureCommandPaletteItem(
id: "action:new-task",
title: "New task in \(activeProject.name)",
detail: activeProject.path,
searchTerms: [
"new task", "new thread", "chat", "create", "draft",
activeProject.name, activeProject.path,
],
systemImage: "square.and.pencil",
action: .newTask(projectID: activeProject.id)
))
}

items.append(FeatureCommandPaletteItem(
id: "action:new-task-in",
title: "New task in…",
detail: "Choose a project",
searchTerms: ["new task", "new thread", "project", "choose", "select"],
systemImage: "square.and.pencil",
action: .chooseNewTaskProject,
showsDisclosure: true
))
}

items.append(FeatureCommandPaletteItem(
id: "action:add-project",
title: "Add project",
detail: "Local folder or remote repository",
searchTerms: [
"add project", "folder", "directory", "browse", "clone", "remote",
"repository", "repo", "git", "github", "gitlab", "bitbucket", "azure",
"devops", "url", "environment",
],
systemImage: "folder.badge.plus",
action: .addProject
))
items.append(FeatureCommandPaletteItem(
id: "action:settings",
title: "Open settings",
detail: "Preferences and connections",
searchTerms: ["settings", "preferences", "configuration", "connections"],
systemImage: "slider.horizontal.3",
action: .settings
))
return items
}

private static func recentThreadItems(
snapshot: FeatureSnapshot
) -> [FeatureCommandPaletteItem] {
Array(threadItems(snapshot: snapshot).prefix(recentThreadLimit))
}

private static func projectItems(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
action: (FeatureProject) -> FeatureCommandPaletteAction = { .openProject(id: $0.id) },
idPrefix: String = "project"
) -> [FeatureCommandPaletteItem] {
let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return projects
.sorted { left, right in
let nameOrder = left.name.localizedStandardCompare(right.name)
if nameOrder != .orderedSame { return nameOrder == .orderedAscending }
return left.id < right.id
}
.map { project in
let environment = environmentNames[project.environmentID]
let detail = [project.path, environment]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "\(idPrefix):\(project.id)",
title: project.name,
detail: detail.isEmpty ? nil : detail,
searchTerms: [project.name, project.path, environment ?? ""],
systemImage: "folder",
action: action(project)
)
}
}

private static func threadItems(snapshot: FeatureSnapshot) -> [FeatureCommandPaletteItem] {
let projectsByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HighWorkspace/FeatureCommandPaletteModel.swift:266

threadItems builds projectsByID with Dictionary(uniqueKeysWithValues:), which traps when the snapshot contains two projects with the same id. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped id, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. Dictionary(_:uniquingKeysWith:) or scoping by environment) instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift around line 266:
`threadItems` builds `projectsByID` with `Dictionary(uniqueKeysWithValues:)`, which traps when the snapshot contains two projects with the same `id`. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped `id`, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. `Dictionary(_:uniquingKeysWith:)` or scoping by environment) instead.

let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return snapshot.threads
.filter { !$0.isArchived }
.sorted { left, right in
let leftDate = left.lastActivityAt ?? left.updatedAt
let rightDate = right.lastActivityAt ?? right.updatedAt
if leftDate != rightDate { return leftDate > rightDate }
return left.id < right.id
}
.map { thread in
let project = projectsByID[thread.projectID]
let environment = thread.environmentID.flatMap { environmentNames[$0] }
?? thread.environmentName
let detail = [
project?.name,
thread.branch.map { "#\($0)" },
environment,
]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "thread:\(thread.id)",
title: thread.title,
detail: detail.isEmpty ? nil : detail,
searchTerms: [
thread.title,
thread.preview ?? "",
thread.branch ?? "",
project?.name ?? "",
project?.path ?? "",
environment ?? "",
],
systemImage: "message",
action: .openThread(id: thread.id)
)
}
}

private static func matchingItems(
_ items: [FeatureCommandPaletteItem],
query: String
) -> [FeatureCommandPaletteItem] {
let tokens = normalizedTokens(query)
guard !tokens.isEmpty else { return items }

return items.enumerated()
.compactMap { index, item -> (item: FeatureCommandPaletteItem, score: Int, index: Int)? in
guard let score = matchScore(item: item, tokens: tokens) else { return nil }
return (item, score, index)
}
.sorted { left, right in
if left.score != right.score { return left.score > right.score }
return left.index < right.index
}
.map(\.item)
}

private static func matchScore(
item: FeatureCommandPaletteItem,
tokens: [String]
) -> Int? {
let fields = [item.title, item.detail ?? ""] + item.searchTerms
var score = 0

for token in tokens {
var tokenScore: Int?
for (index, field) in fields.enumerated() {
guard field.localizedCaseInsensitiveContains(token) else { continue }
let normalizedField = field.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let normalizedToken = token.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let fieldScore: Int
if normalizedField == normalizedToken {
fieldScore = 300
} else if normalizedField.hasPrefix(normalizedToken) {
fieldScore = 200
} else {
fieldScore = 100
}
tokenScore = max(tokenScore ?? 0, fieldScore - min(index, 20))
}
guard let tokenScore else { return nil }
score += tokenScore
}

return score
}

private static func normalizedTokens(_ query: String) -> [String] {
query
.split(whereSeparator: \.isWhitespace)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}

private static func group(
id: String,
title: String,
items: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: id, title: title, items: items)]
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 378 additions & 0 deletions apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
import CoreGraphics
import Foundation

enum FeatureCommandPaletteAction: Hashable {
case newTask(projectID: String?)
case chooseNewTaskProject
case addProject
case settings
case openThread(id: String)
case openProject(id: String)
}

struct FeatureCommandPaletteItem: Identifiable, Equatable {
let id: String
let title: String
let detail: String?
let searchTerms: [String]
let systemImage: String
let action: FeatureCommandPaletteAction
let showsDisclosure: Bool

init(
id: String,
title: String,
detail: String? = nil,
searchTerms: [String] = [],
systemImage: String,
action: FeatureCommandPaletteAction,
showsDisclosure: Bool = false
) {
self.id = id
self.title = title
self.detail = detail
self.searchTerms = searchTerms
self.systemImage = systemImage
self.action = action
self.showsDisclosure = showsDisclosure
}
}

struct FeatureCommandPaletteGroup: Identifiable, Equatable {
let id: String
let title: String
let items: [FeatureCommandPaletteItem]
}

enum FeatureCommandPaletteMode: Equatable {
case root
case newTaskProjectPicker
}

enum FeatureCommandPaletteGesture {
static let maximumStartY: CGFloat = 88
static let minimumDownwardTranslation: CGFloat = 72

static func shouldPresent(startY: CGFloat, translation: CGSize) -> Bool {
guard startY <= maximumStartY,
translation.height >= minimumDownwardTranslation else {
return false
}

return translation.height > abs(translation.width) * 1.15
}
}

enum FeatureCommandPaletteCatalog {
static let recentThreadLimit = 12

static func groups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
activeProjectID: String?,
query: String
) -> [FeatureCommandPaletteGroup] {
let actions = actionItems(projects: projects, activeProjectID: activeProjectID)
let rawQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let actionsOnly = rawQuery.first == ">"
let searchQuery = actionsOnly
? String(rawQuery.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
: rawQuery

if searchQuery.isEmpty {
if actionsOnly {
return group(id: "actions", title: "Actions", items: actions)
}

return rootGroups(
actions: actions,
recentThreads: recentThreadItems(snapshot: snapshot)
)
}

let filteredActions = matchingItems(actions, query: searchQuery)
var groups: [FeatureCommandPaletteGroup] = []
if !filteredActions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: filteredActions
))
}

guard !actionsOnly else { return groups }

let filteredProjects = matchingItems(
projectItems(snapshot: snapshot, projects: projects),
query: searchQuery
)
if !filteredProjects.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "projects-search",
title: "Projects",
items: filteredProjects
))
}

let filteredThreads = matchingItems(
threadItems(snapshot: snapshot),
query: searchQuery
)
if !filteredThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "threads-search",
title: "Threads",
items: filteredThreads
))
}

return groups
}

static func newTaskProjectGroups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
query: String
) -> [FeatureCommandPaletteGroup] {
let items = matchingItems(
projectItems(
snapshot: snapshot,
projects: projects,
action: { .newTask(projectID: $0.id) },
idPrefix: "new-task-in"
),
query: query
)
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: "projects", title: "Projects", items: items)]
}

private static func rootGroups(
actions: [FeatureCommandPaletteItem],
recentThreads: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
var groups: [FeatureCommandPaletteGroup] = []
if !actions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: actions
))
}
if !recentThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "recent-threads",
title: "Recent Threads",
items: recentThreads
))
}
return groups
}

private static func actionItems(
projects: [FeatureProject],
activeProjectID: String?
) -> [FeatureCommandPaletteItem] {
var items: [FeatureCommandPaletteItem] = []
if !projects.isEmpty {
let activeProject = projects.first { $0.id == activeProjectID } ?? projects.first
if let activeProject {
items.append(FeatureCommandPaletteItem(
id: "action:new-task",
title: "New task in \(activeProject.name)",
detail: activeProject.path,
searchTerms: [
"new task", "new thread", "chat", "create", "draft",
activeProject.name, activeProject.path,
],
systemImage: "square.and.pencil",
action: .newTask(projectID: activeProject.id)
))
}

items.append(FeatureCommandPaletteItem(
id: "action:new-task-in",
title: "New task in…",
detail: "Choose a project",
searchTerms: ["new task", "new thread", "project", "choose", "select"],
systemImage: "square.and.pencil",
action: .chooseNewTaskProject,
showsDisclosure: true
))
}

items.append(FeatureCommandPaletteItem(
id: "action:add-project",
title: "Add project",
detail: "Local folder or remote repository",
searchTerms: [
"add project", "folder", "directory", "browse", "clone", "remote",
"repository", "repo", "git", "github", "gitlab", "bitbucket", "azure",
"devops", "url", "environment",
],
systemImage: "folder.badge.plus",
action: .addProject
))
items.append(FeatureCommandPaletteItem(
id: "action:settings",
title: "Open settings",
detail: "Preferences and connections",
searchTerms: ["settings", "preferences", "configuration", "connections"],
systemImage: "slider.horizontal.3",
action: .settings
))
return items
}

private static func recentThreadItems(
snapshot: FeatureSnapshot
) -> [FeatureCommandPaletteItem] {
Array(threadItems(snapshot: snapshot).prefix(recentThreadLimit))
}

private static func projectItems(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
action: (FeatureProject) -> FeatureCommandPaletteAction = { .openProject(id: $0.id) },
idPrefix: String = "project"
) -> [FeatureCommandPaletteItem] {
let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return projects
.sorted { left, right in
let nameOrder = left.name.localizedStandardCompare(right.name)
if nameOrder != .orderedSame { return nameOrder == .orderedAscending }
return left.id < right.id
}
.map { project in
let environment = environmentNames[project.environmentID]
let detail = [project.path, environment]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "\(idPrefix):\(project.id)",
title: project.name,
detail: detail.isEmpty ? nil : detail,
searchTerms: [project.name, project.path, environment ?? ""],
systemImage: "folder",
action: action(project)
)
}
}

private static func threadItems(snapshot: FeatureSnapshot) -> [FeatureCommandPaletteItem] {
let projectsByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HighWorkspace/FeatureCommandPaletteModel.swift:266

threadItems builds projectsByID with Dictionary(uniqueKeysWithValues:), which traps when the snapshot contains two projects with the same id. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped id, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. Dictionary(_:uniquingKeysWith:) or scoping by environment) instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift around line 266:
`threadItems` builds `projectsByID` with `Dictionary(uniqueKeysWithValues:)`, which traps when the snapshot contains two projects with the same `id`. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped `id`, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. `Dictionary(_:uniquingKeysWith:)` or scoping by environment) instead.

let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return snapshot.threads
.filter { !$0.isArchived }
.sorted { left, right in
let leftDate = left.lastActivityAt ?? left.updatedAt
let rightDate = right.lastActivityAt ?? right.updatedAt
if leftDate != rightDate { return leftDate > rightDate }
return left.id < right.id
}
.map { thread in
let project = projectsByID[thread.projectID]
let environment = thread.environmentID.flatMap { environmentNames[$0] }
?? thread.environmentName
let detail = [
project?.name,
thread.branch.map { "#\($0)" },
environment,
]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "thread:\(thread.id)",
title: thread.title,
detail: detail.isEmpty ? nil : detail,
searchTerms: [
thread.title,
thread.preview ?? "",
thread.branch ?? "",
project?.name ?? "",
project?.path ?? "",
environment ?? "",
],
systemImage: "message",
action: .openThread(id: thread.id)
)
}
}

private static func matchingItems(
_ items: [FeatureCommandPaletteItem],
query: String
) -> [FeatureCommandPaletteItem] {
let tokens = normalizedTokens(query)
guard !tokens.isEmpty else { return items }

return items.enumerated()
.compactMap { index, item -> (item: FeatureCommandPaletteItem, score: Int, index: Int)? in
guard let score = matchScore(item: item, tokens: tokens) else { return nil }
return (item, score, index)
}
.sorted { left, right in
if left.score != right.score { return left.score > right.score }
return left.index < right.index
}
.map(\.item)
}

private static func matchScore(
item: FeatureCommandPaletteItem,
tokens: [String]
) -> Int? {
let fields = [item.title, item.detail ?? ""] + item.searchTerms
var score = 0

for token in tokens {
var tokenScore: Int?
for (index, field) in fields.enumerated() {
guard field.localizedCaseInsensitiveContains(token) else { continue }
let normalizedField = field.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let normalizedToken = token.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let fieldScore: Int
if normalizedField == normalizedToken {
fieldScore = 300
} else if normalizedField.hasPrefix(normalizedToken) {
fieldScore = 200
} else {
fieldScore = 100
}
tokenScore = max(tokenScore ?? 0, fieldScore - min(index, 20))
}
guard let tokenScore else { return nil }
score += tokenScore
}

return score
}

private static func normalizedTokens(_ query: String) -> [String] {
query
.split(whereSeparator: \.isWhitespace)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}

private static func group(
id: String,
title: String,
items: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: id, title: title, items: items)]
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 378 additions & 0 deletions apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
import CoreGraphics
import Foundation

enum FeatureCommandPaletteAction: Hashable {
case newTask(projectID: String?)
case chooseNewTaskProject
case addProject
case settings
case openThread(id: String)
case openProject(id: String)
}

struct FeatureCommandPaletteItem: Identifiable, Equatable {
let id: String
let title: String
let detail: String?
let searchTerms: [String]
let systemImage: String
let action: FeatureCommandPaletteAction
let showsDisclosure: Bool

init(
id: String,
title: String,
detail: String? = nil,
searchTerms: [String] = [],
systemImage: String,
action: FeatureCommandPaletteAction,
showsDisclosure: Bool = false
) {
self.id = id
self.title = title
self.detail = detail
self.searchTerms = searchTerms
self.systemImage = systemImage
self.action = action
self.showsDisclosure = showsDisclosure
}
}

struct FeatureCommandPaletteGroup: Identifiable, Equatable {
let id: String
let title: String
let items: [FeatureCommandPaletteItem]
}

enum FeatureCommandPaletteMode: Equatable {
case root
case newTaskProjectPicker
}

enum FeatureCommandPaletteGesture {
static let maximumStartY: CGFloat = 88
static let minimumDownwardTranslation: CGFloat = 72

static func shouldPresent(startY: CGFloat, translation: CGSize) -> Bool {
guard startY <= maximumStartY,
translation.height >= minimumDownwardTranslation else {
return false
}

return translation.height > abs(translation.width) * 1.15
}
}

enum FeatureCommandPaletteCatalog {
static let recentThreadLimit = 12

static func groups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
activeProjectID: String?,
query: String
) -> [FeatureCommandPaletteGroup] {
let actions = actionItems(projects: projects, activeProjectID: activeProjectID)
let rawQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let actionsOnly = rawQuery.first == ">"
let searchQuery = actionsOnly
? String(rawQuery.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
: rawQuery

if searchQuery.isEmpty {
if actionsOnly {
return group(id: "actions", title: "Actions", items: actions)
}

return rootGroups(
actions: actions,
recentThreads: recentThreadItems(snapshot: snapshot)
)
}

let filteredActions = matchingItems(actions, query: searchQuery)
var groups: [FeatureCommandPaletteGroup] = []
if !filteredActions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: filteredActions
))
}

guard !actionsOnly else { return groups }

let filteredProjects = matchingItems(
projectItems(snapshot: snapshot, projects: projects),
query: searchQuery
)
if !filteredProjects.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "projects-search",
title: "Projects",
items: filteredProjects
))
}

let filteredThreads = matchingItems(
threadItems(snapshot: snapshot),
query: searchQuery
)
if !filteredThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "threads-search",
title: "Threads",
items: filteredThreads
))
}

return groups
}

static func newTaskProjectGroups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
query: String
) -> [FeatureCommandPaletteGroup] {
let items = matchingItems(
projectItems(
snapshot: snapshot,
projects: projects,
action: { .newTask(projectID: $0.id) },
idPrefix: "new-task-in"
),
query: query
)
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: "projects", title: "Projects", items: items)]
}

private static func rootGroups(
actions: [FeatureCommandPaletteItem],
recentThreads: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
var groups: [FeatureCommandPaletteGroup] = []
if !actions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: actions
))
}
if !recentThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "recent-threads",
title: "Recent Threads",
items: recentThreads
))
}
return groups
}

private static func actionItems(
projects: [FeatureProject],
activeProjectID: String?
) -> [FeatureCommandPaletteItem] {
var items: [FeatureCommandPaletteItem] = []
if !projects.isEmpty {
let activeProject = projects.first { $0.id == activeProjectID } ?? projects.first
if let activeProject {
items.append(FeatureCommandPaletteItem(
id: "action:new-task",
title: "New task in \(activeProject.name)",
detail: activeProject.path,
searchTerms: [
"new task", "new thread", "chat", "create", "draft",
activeProject.name, activeProject.path,
],
systemImage: "square.and.pencil",
action: .newTask(projectID: activeProject.id)
))
}

items.append(FeatureCommandPaletteItem(
id: "action:new-task-in",
title: "New task in…",
detail: "Choose a project",
searchTerms: ["new task", "new thread", "project", "choose", "select"],
systemImage: "square.and.pencil",
action: .chooseNewTaskProject,
showsDisclosure: true
))
}

items.append(FeatureCommandPaletteItem(
id: "action:add-project",
title: "Add project",
detail: "Local folder or remote repository",
searchTerms: [
"add project", "folder", "directory", "browse", "clone", "remote",
"repository", "repo", "git", "github", "gitlab", "bitbucket", "azure",
"devops", "url", "environment",
],
systemImage: "folder.badge.plus",
action: .addProject
))
items.append(FeatureCommandPaletteItem(
id: "action:settings",
title: "Open settings",
detail: "Preferences and connections",
searchTerms: ["settings", "preferences", "configuration", "connections"],
systemImage: "slider.horizontal.3",
action: .settings
))
return items
}

private static func recentThreadItems(
snapshot: FeatureSnapshot
) -> [FeatureCommandPaletteItem] {
Array(threadItems(snapshot: snapshot).prefix(recentThreadLimit))
}

private static func projectItems(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
action: (FeatureProject) -> FeatureCommandPaletteAction = { .openProject(id: $0.id) },
idPrefix: String = "project"
) -> [FeatureCommandPaletteItem] {
let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return projects
.sorted { left, right in
let nameOrder = left.name.localizedStandardCompare(right.name)
if nameOrder != .orderedSame { return nameOrder == .orderedAscending }
return left.id < right.id
}
.map { project in
let environment = environmentNames[project.environmentID]
let detail = [project.path, environment]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "\(idPrefix):\(project.id)",
title: project.name,
detail: detail.isEmpty ? nil : detail,
searchTerms: [project.name, project.path, environment ?? ""],
systemImage: "folder",
action: action(project)
)
}
}

private static func threadItems(snapshot: FeatureSnapshot) -> [FeatureCommandPaletteItem] {
let projectsByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HighWorkspace/FeatureCommandPaletteModel.swift:266

threadItems builds projectsByID with Dictionary(uniqueKeysWithValues:), which traps when the snapshot contains two projects with the same id. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped id, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. Dictionary(_:uniquingKeysWith:) or scoping by environment) instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift around line 266:
`threadItems` builds `projectsByID` with `Dictionary(uniqueKeysWithValues:)`, which traps when the snapshot contains two projects with the same `id`. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped `id`, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. `Dictionary(_:uniquingKeysWith:)` or scoping by environment) instead.

let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return snapshot.threads
.filter { !$0.isArchived }
.sorted { left, right in
let leftDate = left.lastActivityAt ?? left.updatedAt
let rightDate = right.lastActivityAt ?? right.updatedAt
if leftDate != rightDate { return leftDate > rightDate }
return left.id < right.id
}
.map { thread in
let project = projectsByID[thread.projectID]
let environment = thread.environmentID.flatMap { environmentNames[$0] }
?? thread.environmentName
let detail = [
project?.name,
thread.branch.map { "#\($0)" },
environment,
]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "thread:\(thread.id)",
title: thread.title,
detail: detail.isEmpty ? nil : detail,
searchTerms: [
thread.title,
thread.preview ?? "",
thread.branch ?? "",
project?.name ?? "",
project?.path ?? "",
environment ?? "",
],
systemImage: "message",
action: .openThread(id: thread.id)
)
}
}

private static func matchingItems(
_ items: [FeatureCommandPaletteItem],
query: String
) -> [FeatureCommandPaletteItem] {
let tokens = normalizedTokens(query)
guard !tokens.isEmpty else { return items }

return items.enumerated()
.compactMap { index, item -> (item: FeatureCommandPaletteItem, score: Int, index: Int)? in
guard let score = matchScore(item: item, tokens: tokens) else { return nil }
return (item, score, index)
}
.sorted { left, right in
if left.score != right.score { return left.score > right.score }
return left.index < right.index
}
.map(\.item)
}

private static func matchScore(
item: FeatureCommandPaletteItem,
tokens: [String]
) -> Int? {
let fields = [item.title, item.detail ?? ""] + item.searchTerms
var score = 0

for token in tokens {
var tokenScore: Int?
for (index, field) in fields.enumerated() {
guard field.localizedCaseInsensitiveContains(token) else { continue }
let normalizedField = field.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let normalizedToken = token.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let fieldScore: Int
if normalizedField == normalizedToken {
fieldScore = 300
} else if normalizedField.hasPrefix(normalizedToken) {
fieldScore = 200
} else {
fieldScore = 100
}
tokenScore = max(tokenScore ?? 0, fieldScore - min(index, 20))
}
guard let tokenScore else { return nil }
score += tokenScore
}

return score
}

private static func normalizedTokens(_ query: String) -> [String] {
query
.split(whereSeparator: \.isWhitespace)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}

private static func group(
id: String,
title: String,
items: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: id, title: title, items: items)]
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 378 additions & 0 deletions apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
import CoreGraphics
import Foundation

enum FeatureCommandPaletteAction: Hashable {
case newTask(projectID: String?)
case chooseNewTaskProject
case addProject
case settings
case openThread(id: String)
case openProject(id: String)
}

struct FeatureCommandPaletteItem: Identifiable, Equatable {
let id: String
let title: String
let detail: String?
let searchTerms: [String]
let systemImage: String
let action: FeatureCommandPaletteAction
let showsDisclosure: Bool

init(
id: String,
title: String,
detail: String? = nil,
searchTerms: [String] = [],
systemImage: String,
action: FeatureCommandPaletteAction,
showsDisclosure: Bool = false
) {
self.id = id
self.title = title
self.detail = detail
self.searchTerms = searchTerms
self.systemImage = systemImage
self.action = action
self.showsDisclosure = showsDisclosure
}
}

struct FeatureCommandPaletteGroup: Identifiable, Equatable {
let id: String
let title: String
let items: [FeatureCommandPaletteItem]
}

enum FeatureCommandPaletteMode: Equatable {
case root
case newTaskProjectPicker
}

enum FeatureCommandPaletteGesture {
static let maximumStartY: CGFloat = 88
static let minimumDownwardTranslation: CGFloat = 72

static func shouldPresent(startY: CGFloat, translation: CGSize) -> Bool {
guard startY <= maximumStartY,
translation.height >= minimumDownwardTranslation else {
return false
}

return translation.height > abs(translation.width) * 1.15
}
}

enum FeatureCommandPaletteCatalog {
static let recentThreadLimit = 12

static func groups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
activeProjectID: String?,
query: String
) -> [FeatureCommandPaletteGroup] {
let actions = actionItems(projects: projects, activeProjectID: activeProjectID)
let rawQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let actionsOnly = rawQuery.first == ">"
let searchQuery = actionsOnly
? String(rawQuery.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
: rawQuery

if searchQuery.isEmpty {
if actionsOnly {
return group(id: "actions", title: "Actions", items: actions)
}

return rootGroups(
actions: actions,
recentThreads: recentThreadItems(snapshot: snapshot)
)
}

let filteredActions = matchingItems(actions, query: searchQuery)
var groups: [FeatureCommandPaletteGroup] = []
if !filteredActions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: filteredActions
))
}

guard !actionsOnly else { return groups }

let filteredProjects = matchingItems(
projectItems(snapshot: snapshot, projects: projects),
query: searchQuery
)
if !filteredProjects.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "projects-search",
title: "Projects",
items: filteredProjects
))
}

let filteredThreads = matchingItems(
threadItems(snapshot: snapshot),
query: searchQuery
)
if !filteredThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "threads-search",
title: "Threads",
items: filteredThreads
))
}

return groups
}

static func newTaskProjectGroups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
query: String
) -> [FeatureCommandPaletteGroup] {
let items = matchingItems(
projectItems(
snapshot: snapshot,
projects: projects,
action: { .newTask(projectID: $0.id) },
idPrefix: "new-task-in"
),
query: query
)
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: "projects", title: "Projects", items: items)]
}

private static func rootGroups(
actions: [FeatureCommandPaletteItem],
recentThreads: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
var groups: [FeatureCommandPaletteGroup] = []
if !actions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: actions
))
}
if !recentThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "recent-threads",
title: "Recent Threads",
items: recentThreads
))
}
return groups
}

private static func actionItems(
projects: [FeatureProject],
activeProjectID: String?
) -> [FeatureCommandPaletteItem] {
var items: [FeatureCommandPaletteItem] = []
if !projects.isEmpty {
let activeProject = projects.first { $0.id == activeProjectID } ?? projects.first
if let activeProject {
items.append(FeatureCommandPaletteItem(
id: "action:new-task",
title: "New task in \(activeProject.name)",
detail: activeProject.path,
searchTerms: [
"new task", "new thread", "chat", "create", "draft",
activeProject.name, activeProject.path,
],
systemImage: "square.and.pencil",
action: .newTask(projectID: activeProject.id)
))
}

items.append(FeatureCommandPaletteItem(
id: "action:new-task-in",
title: "New task in…",
detail: "Choose a project",
searchTerms: ["new task", "new thread", "project", "choose", "select"],
systemImage: "square.and.pencil",
action: .chooseNewTaskProject,
showsDisclosure: true
))
}

items.append(FeatureCommandPaletteItem(
id: "action:add-project",
title: "Add project",
detail: "Local folder or remote repository",
searchTerms: [
"add project", "folder", "directory", "browse", "clone", "remote",
"repository", "repo", "git", "github", "gitlab", "bitbucket", "azure",
"devops", "url", "environment",
],
systemImage: "folder.badge.plus",
action: .addProject
))
items.append(FeatureCommandPaletteItem(
id: "action:settings",
title: "Open settings",
detail: "Preferences and connections",
searchTerms: ["settings", "preferences", "configuration", "connections"],
systemImage: "slider.horizontal.3",
action: .settings
))
return items
}

private static func recentThreadItems(
snapshot: FeatureSnapshot
) -> [FeatureCommandPaletteItem] {
Array(threadItems(snapshot: snapshot).prefix(recentThreadLimit))
}

private static func projectItems(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
action: (FeatureProject) -> FeatureCommandPaletteAction = { .openProject(id: $0.id) },
idPrefix: String = "project"
) -> [FeatureCommandPaletteItem] {
let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return projects
.sorted { left, right in
let nameOrder = left.name.localizedStandardCompare(right.name)
if nameOrder != .orderedSame { return nameOrder == .orderedAscending }
return left.id < right.id
}
.map { project in
let environment = environmentNames[project.environmentID]
let detail = [project.path, environment]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "\(idPrefix):\(project.id)",
title: project.name,
detail: detail.isEmpty ? nil : detail,
searchTerms: [project.name, project.path, environment ?? ""],
systemImage: "folder",
action: action(project)
)
}
}

private static func threadItems(snapshot: FeatureSnapshot) -> [FeatureCommandPaletteItem] {
let projectsByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HighWorkspace/FeatureCommandPaletteModel.swift:266

threadItems builds projectsByID with Dictionary(uniqueKeysWithValues:), which traps when the snapshot contains two projects with the same id. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped id, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. Dictionary(_:uniquingKeysWith:) or scoping by environment) instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift around line 266:
`threadItems` builds `projectsByID` with `Dictionary(uniqueKeysWithValues:)`, which traps when the snapshot contains two projects with the same `id`. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped `id`, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. `Dictionary(_:uniquingKeysWith:)` or scoping by environment) instead.

let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return snapshot.threads
.filter { !$0.isArchived }
.sorted { left, right in
let leftDate = left.lastActivityAt ?? left.updatedAt
let rightDate = right.lastActivityAt ?? right.updatedAt
if leftDate != rightDate { return leftDate > rightDate }
return left.id < right.id
}
.map { thread in
let project = projectsByID[thread.projectID]
let environment = thread.environmentID.flatMap { environmentNames[$0] }
?? thread.environmentName
let detail = [
project?.name,
thread.branch.map { "#\($0)" },
environment,
]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "thread:\(thread.id)",
title: thread.title,
detail: detail.isEmpty ? nil : detail,
searchTerms: [
thread.title,
thread.preview ?? "",
thread.branch ?? "",
project?.name ?? "",
project?.path ?? "",
environment ?? "",
],
systemImage: "message",
action: .openThread(id: thread.id)
)
}
}

private static func matchingItems(
_ items: [FeatureCommandPaletteItem],
query: String
) -> [FeatureCommandPaletteItem] {
let tokens = normalizedTokens(query)
guard !tokens.isEmpty else { return items }

return items.enumerated()
.compactMap { index, item -> (item: FeatureCommandPaletteItem, score: Int, index: Int)? in
guard let score = matchScore(item: item, tokens: tokens) else { return nil }
return (item, score, index)
}
.sorted { left, right in
if left.score != right.score { return left.score > right.score }
return left.index < right.index
}
.map(\.item)
}

private static func matchScore(
item: FeatureCommandPaletteItem,
tokens: [String]
) -> Int? {
let fields = [item.title, item.detail ?? ""] + item.searchTerms
var score = 0

for token in tokens {
var tokenScore: Int?
for (index, field) in fields.enumerated() {
guard field.localizedCaseInsensitiveContains(token) else { continue }
let normalizedField = field.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let normalizedToken = token.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let fieldScore: Int
if normalizedField == normalizedToken {
fieldScore = 300
} else if normalizedField.hasPrefix(normalizedToken) {
fieldScore = 200
} else {
fieldScore = 100
}
tokenScore = max(tokenScore ?? 0, fieldScore - min(index, 20))
}
guard let tokenScore else { return nil }
score += tokenScore
}

return score
}

private static func normalizedTokens(_ query: String) -> [String] {
query
.split(whereSeparator: \.isWhitespace)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}

private static func group(
id: String,
title: String,
items: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: id, title: title, items: items)]
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 378 additions & 0 deletions apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
import CoreGraphics
import Foundation

enum FeatureCommandPaletteAction: Hashable {
case newTask(projectID: String?)
case chooseNewTaskProject
case addProject
case settings
case openThread(id: String)
case openProject(id: String)
}

struct FeatureCommandPaletteItem: Identifiable, Equatable {
let id: String
let title: String
let detail: String?
let searchTerms: [String]
let systemImage: String
let action: FeatureCommandPaletteAction
let showsDisclosure: Bool

init(
id: String,
title: String,
detail: String? = nil,
searchTerms: [String] = [],
systemImage: String,
action: FeatureCommandPaletteAction,
showsDisclosure: Bool = false
) {
self.id = id
self.title = title
self.detail = detail
self.searchTerms = searchTerms
self.systemImage = systemImage
self.action = action
self.showsDisclosure = showsDisclosure
}
}

struct FeatureCommandPaletteGroup: Identifiable, Equatable {
let id: String
let title: String
let items: [FeatureCommandPaletteItem]
}

enum FeatureCommandPaletteMode: Equatable {
case root
case newTaskProjectPicker
}

enum FeatureCommandPaletteGesture {
static let maximumStartY: CGFloat = 88
static let minimumDownwardTranslation: CGFloat = 72

static func shouldPresent(startY: CGFloat, translation: CGSize) -> Bool {
guard startY <= maximumStartY,
translation.height >= minimumDownwardTranslation else {
return false
}

return translation.height > abs(translation.width) * 1.15
}
}

enum FeatureCommandPaletteCatalog {
static let recentThreadLimit = 12

static func groups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
activeProjectID: String?,
query: String
) -> [FeatureCommandPaletteGroup] {
let actions = actionItems(projects: projects, activeProjectID: activeProjectID)
let rawQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let actionsOnly = rawQuery.first == ">"
let searchQuery = actionsOnly
? String(rawQuery.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
: rawQuery

if searchQuery.isEmpty {
if actionsOnly {
return group(id: "actions", title: "Actions", items: actions)
}

return rootGroups(
actions: actions,
recentThreads: recentThreadItems(snapshot: snapshot)
)
}

let filteredActions = matchingItems(actions, query: searchQuery)
var groups: [FeatureCommandPaletteGroup] = []
if !filteredActions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: filteredActions
))
}

guard !actionsOnly else { return groups }

let filteredProjects = matchingItems(
projectItems(snapshot: snapshot, projects: projects),
query: searchQuery
)
if !filteredProjects.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "projects-search",
title: "Projects",
items: filteredProjects
))
}

let filteredThreads = matchingItems(
threadItems(snapshot: snapshot),
query: searchQuery
)
if !filteredThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "threads-search",
title: "Threads",
items: filteredThreads
))
}

return groups
}

static func newTaskProjectGroups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
query: String
) -> [FeatureCommandPaletteGroup] {
let items = matchingItems(
projectItems(
snapshot: snapshot,
projects: projects,
action: { .newTask(projectID: $0.id) },
idPrefix: "new-task-in"
),
query: query
)
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: "projects", title: "Projects", items: items)]
}

private static func rootGroups(
actions: [FeatureCommandPaletteItem],
recentThreads: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
var groups: [FeatureCommandPaletteGroup] = []
if !actions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: actions
))
}
if !recentThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "recent-threads",
title: "Recent Threads",
items: recentThreads
))
}
return groups
}

private static func actionItems(
projects: [FeatureProject],
activeProjectID: String?
) -> [FeatureCommandPaletteItem] {
var items: [FeatureCommandPaletteItem] = []
if !projects.isEmpty {
let activeProject = projects.first { $0.id == activeProjectID } ?? projects.first
if let activeProject {
items.append(FeatureCommandPaletteItem(
id: "action:new-task",
title: "New task in \(activeProject.name)",
detail: activeProject.path,
searchTerms: [
"new task", "new thread", "chat", "create", "draft",
activeProject.name, activeProject.path,
],
systemImage: "square.and.pencil",
action: .newTask(projectID: activeProject.id)
))
}

items.append(FeatureCommandPaletteItem(
id: "action:new-task-in",
title: "New task in…",
detail: "Choose a project",
searchTerms: ["new task", "new thread", "project", "choose", "select"],
systemImage: "square.and.pencil",
action: .chooseNewTaskProject,
showsDisclosure: true
))
}

items.append(FeatureCommandPaletteItem(
id: "action:add-project",
title: "Add project",
detail: "Local folder or remote repository",
searchTerms: [
"add project", "folder", "directory", "browse", "clone", "remote",
"repository", "repo", "git", "github", "gitlab", "bitbucket", "azure",
"devops", "url", "environment",
],
systemImage: "folder.badge.plus",
action: .addProject
))
items.append(FeatureCommandPaletteItem(
id: "action:settings",
title: "Open settings",
detail: "Preferences and connections",
searchTerms: ["settings", "preferences", "configuration", "connections"],
systemImage: "slider.horizontal.3",
action: .settings
))
return items
}

private static func recentThreadItems(
snapshot: FeatureSnapshot
) -> [FeatureCommandPaletteItem] {
Array(threadItems(snapshot: snapshot).prefix(recentThreadLimit))
}

private static func projectItems(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
action: (FeatureProject) -> FeatureCommandPaletteAction = { .openProject(id: $0.id) },
idPrefix: String = "project"
) -> [FeatureCommandPaletteItem] {
let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return projects
.sorted { left, right in
let nameOrder = left.name.localizedStandardCompare(right.name)
if nameOrder != .orderedSame { return nameOrder == .orderedAscending }
return left.id < right.id
}
.map { project in
let environment = environmentNames[project.environmentID]
let detail = [project.path, environment]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "\(idPrefix):\(project.id)",
title: project.name,
detail: detail.isEmpty ? nil : detail,
searchTerms: [project.name, project.path, environment ?? ""],
systemImage: "folder",
action: action(project)
)
}
}

private static func threadItems(snapshot: FeatureSnapshot) -> [FeatureCommandPaletteItem] {
let projectsByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HighWorkspace/FeatureCommandPaletteModel.swift:266

threadItems builds projectsByID with Dictionary(uniqueKeysWithValues:), which traps when the snapshot contains two projects with the same id. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped id, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. Dictionary(_:uniquingKeysWith:) or scoping by environment) instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift around line 266:
`threadItems` builds `projectsByID` with `Dictionary(uniqueKeysWithValues:)`, which traps when the snapshot contains two projects with the same `id`. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped `id`, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. `Dictionary(_:uniquingKeysWith:)` or scoping by environment) instead.

let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return snapshot.threads
.filter { !$0.isArchived }
.sorted { left, right in
let leftDate = left.lastActivityAt ?? left.updatedAt
let rightDate = right.lastActivityAt ?? right.updatedAt
if leftDate != rightDate { return leftDate > rightDate }
return left.id < right.id
}
.map { thread in
let project = projectsByID[thread.projectID]
let environment = thread.environmentID.flatMap { environmentNames[$0] }
?? thread.environmentName
let detail = [
project?.name,
thread.branch.map { "#\($0)" },
environment,
]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "thread:\(thread.id)",
title: thread.title,
detail: detail.isEmpty ? nil : detail,
searchTerms: [
thread.title,
thread.preview ?? "",
thread.branch ?? "",
project?.name ?? "",
project?.path ?? "",
environment ?? "",
],
systemImage: "message",
action: .openThread(id: thread.id)
)
}
}

private static func matchingItems(
_ items: [FeatureCommandPaletteItem],
query: String
) -> [FeatureCommandPaletteItem] {
let tokens = normalizedTokens(query)
guard !tokens.isEmpty else { return items }

return items.enumerated()
.compactMap { index, item -> (item: FeatureCommandPaletteItem, score: Int, index: Int)? in
guard let score = matchScore(item: item, tokens: tokens) else { return nil }
return (item, score, index)
}
.sorted { left, right in
if left.score != right.score { return left.score > right.score }
return left.index < right.index
}
.map(\.item)
}

private static func matchScore(
item: FeatureCommandPaletteItem,
tokens: [String]
) -> Int? {
let fields = [item.title, item.detail ?? ""] + item.searchTerms
var score = 0

for token in tokens {
var tokenScore: Int?
for (index, field) in fields.enumerated() {
guard field.localizedCaseInsensitiveContains(token) else { continue }
let normalizedField = field.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let normalizedToken = token.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let fieldScore: Int
if normalizedField == normalizedToken {
fieldScore = 300
} else if normalizedField.hasPrefix(normalizedToken) {
fieldScore = 200
} else {
fieldScore = 100
}
tokenScore = max(tokenScore ?? 0, fieldScore - min(index, 20))
}
guard let tokenScore else { return nil }
score += tokenScore
}

return score
}

private static func normalizedTokens(_ query: String) -> [String] {
query
.split(whereSeparator: \.isWhitespace)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}

private static func group(
id: String,
title: String,
items: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: id, title: title, items: items)]
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 378 additions & 0 deletions apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
import CoreGraphics
import Foundation

enum FeatureCommandPaletteAction: Hashable {
case newTask(projectID: String?)
case chooseNewTaskProject
case addProject
case settings
case openThread(id: String)
case openProject(id: String)
}

struct FeatureCommandPaletteItem: Identifiable, Equatable {
let id: String
let title: String
let detail: String?
let searchTerms: [String]
let systemImage: String
let action: FeatureCommandPaletteAction
let showsDisclosure: Bool

init(
id: String,
title: String,
detail: String? = nil,
searchTerms: [String] = [],
systemImage: String,
action: FeatureCommandPaletteAction,
showsDisclosure: Bool = false
) {
self.id = id
self.title = title
self.detail = detail
self.searchTerms = searchTerms
self.systemImage = systemImage
self.action = action
self.showsDisclosure = showsDisclosure
}
}

struct FeatureCommandPaletteGroup: Identifiable, Equatable {
let id: String
let title: String
let items: [FeatureCommandPaletteItem]
}

enum FeatureCommandPaletteMode: Equatable {
case root
case newTaskProjectPicker
}

enum FeatureCommandPaletteGesture {
static let maximumStartY: CGFloat = 88
static let minimumDownwardTranslation: CGFloat = 72

static func shouldPresent(startY: CGFloat, translation: CGSize) -> Bool {
guard startY <= maximumStartY,
translation.height >= minimumDownwardTranslation else {
return false
}

return translation.height > abs(translation.width) * 1.15
}
}

enum FeatureCommandPaletteCatalog {
static let recentThreadLimit = 12

static func groups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
activeProjectID: String?,
query: String
) -> [FeatureCommandPaletteGroup] {
let actions = actionItems(projects: projects, activeProjectID: activeProjectID)
let rawQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let actionsOnly = rawQuery.first == ">"
let searchQuery = actionsOnly
? String(rawQuery.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
: rawQuery

if searchQuery.isEmpty {
if actionsOnly {
return group(id: "actions", title: "Actions", items: actions)
}

return rootGroups(
actions: actions,
recentThreads: recentThreadItems(snapshot: snapshot)
)
}

let filteredActions = matchingItems(actions, query: searchQuery)
var groups: [FeatureCommandPaletteGroup] = []
if !filteredActions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: filteredActions
))
}

guard !actionsOnly else { return groups }

let filteredProjects = matchingItems(
projectItems(snapshot: snapshot, projects: projects),
query: searchQuery
)
if !filteredProjects.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "projects-search",
title: "Projects",
items: filteredProjects
))
}

let filteredThreads = matchingItems(
threadItems(snapshot: snapshot),
query: searchQuery
)
if !filteredThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "threads-search",
title: "Threads",
items: filteredThreads
))
}

return groups
}

static func newTaskProjectGroups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
query: String
) -> [FeatureCommandPaletteGroup] {
let items = matchingItems(
projectItems(
snapshot: snapshot,
projects: projects,
action: { .newTask(projectID: $0.id) },
idPrefix: "new-task-in"
),
query: query
)
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: "projects", title: "Projects", items: items)]
}

private static func rootGroups(
actions: [FeatureCommandPaletteItem],
recentThreads: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
var groups: [FeatureCommandPaletteGroup] = []
if !actions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: actions
))
}
if !recentThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "recent-threads",
title: "Recent Threads",
items: recentThreads
))
}
return groups
}

private static func actionItems(
projects: [FeatureProject],
activeProjectID: String?
) -> [FeatureCommandPaletteItem] {
var items: [FeatureCommandPaletteItem] = []
if !projects.isEmpty {
let activeProject = projects.first { $0.id == activeProjectID } ?? projects.first
if let activeProject {
items.append(FeatureCommandPaletteItem(
id: "action:new-task",
title: "New task in \(activeProject.name)",
detail: activeProject.path,
searchTerms: [
"new task", "new thread", "chat", "create", "draft",
activeProject.name, activeProject.path,
],
systemImage: "square.and.pencil",
action: .newTask(projectID: activeProject.id)
))
}

items.append(FeatureCommandPaletteItem(
id: "action:new-task-in",
title: "New task in…",
detail: "Choose a project",
searchTerms: ["new task", "new thread", "project", "choose", "select"],
systemImage: "square.and.pencil",
action: .chooseNewTaskProject,
showsDisclosure: true
))
}

items.append(FeatureCommandPaletteItem(
id: "action:add-project",
title: "Add project",
detail: "Local folder or remote repository",
searchTerms: [
"add project", "folder", "directory", "browse", "clone", "remote",
"repository", "repo", "git", "github", "gitlab", "bitbucket", "azure",
"devops", "url", "environment",
],
systemImage: "folder.badge.plus",
action: .addProject
))
items.append(FeatureCommandPaletteItem(
id: "action:settings",
title: "Open settings",
detail: "Preferences and connections",
searchTerms: ["settings", "preferences", "configuration", "connections"],
systemImage: "slider.horizontal.3",
action: .settings
))
return items
}

private static func recentThreadItems(
snapshot: FeatureSnapshot
) -> [FeatureCommandPaletteItem] {
Array(threadItems(snapshot: snapshot).prefix(recentThreadLimit))
}

private static func projectItems(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
action: (FeatureProject) -> FeatureCommandPaletteAction = { .openProject(id: $0.id) },
idPrefix: String = "project"
) -> [FeatureCommandPaletteItem] {
let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return projects
.sorted { left, right in
let nameOrder = left.name.localizedStandardCompare(right.name)
if nameOrder != .orderedSame { return nameOrder == .orderedAscending }
return left.id < right.id
}
.map { project in
let environment = environmentNames[project.environmentID]
let detail = [project.path, environment]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "\(idPrefix):\(project.id)",
title: project.name,
detail: detail.isEmpty ? nil : detail,
searchTerms: [project.name, project.path, environment ?? ""],
systemImage: "folder",
action: action(project)
)
}
}

private static func threadItems(snapshot: FeatureSnapshot) -> [FeatureCommandPaletteItem] {
let projectsByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HighWorkspace/FeatureCommandPaletteModel.swift:266

threadItems builds projectsByID with Dictionary(uniqueKeysWithValues:), which traps when the snapshot contains two projects with the same id. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped id, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. Dictionary(_:uniquingKeysWith:) or scoping by environment) instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift around line 266:
`threadItems` builds `projectsByID` with `Dictionary(uniqueKeysWithValues:)`, which traps when the snapshot contains two projects with the same `id`. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped `id`, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. `Dictionary(_:uniquingKeysWith:)` or scoping by environment) instead.

let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return snapshot.threads
.filter { !$0.isArchived }
.sorted { left, right in
let leftDate = left.lastActivityAt ?? left.updatedAt
let rightDate = right.lastActivityAt ?? right.updatedAt
if leftDate != rightDate { return leftDate > rightDate }
return left.id < right.id
}
.map { thread in
let project = projectsByID[thread.projectID]
let environment = thread.environmentID.flatMap { environmentNames[$0] }
?? thread.environmentName
let detail = [
project?.name,
thread.branch.map { "#\($0)" },
environment,
]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "thread:\(thread.id)",
title: thread.title,
detail: detail.isEmpty ? nil : detail,
searchTerms: [
thread.title,
thread.preview ?? "",
thread.branch ?? "",
project?.name ?? "",
project?.path ?? "",
environment ?? "",
],
systemImage: "message",
action: .openThread(id: thread.id)
)
}
}

private static func matchingItems(
_ items: [FeatureCommandPaletteItem],
query: String
) -> [FeatureCommandPaletteItem] {
let tokens = normalizedTokens(query)
guard !tokens.isEmpty else { return items }

return items.enumerated()
.compactMap { index, item -> (item: FeatureCommandPaletteItem, score: Int, index: Int)? in
guard let score = matchScore(item: item, tokens: tokens) else { return nil }
return (item, score, index)
}
.sorted { left, right in
if left.score != right.score { return left.score > right.score }
return left.index < right.index
}
.map(\.item)
}

private static func matchScore(
item: FeatureCommandPaletteItem,
tokens: [String]
) -> Int? {
let fields = [item.title, item.detail ?? ""] + item.searchTerms
var score = 0

for token in tokens {
var tokenScore: Int?
for (index, field) in fields.enumerated() {
guard field.localizedCaseInsensitiveContains(token) else { continue }
let normalizedField = field.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let normalizedToken = token.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let fieldScore: Int
if normalizedField == normalizedToken {
fieldScore = 300
} else if normalizedField.hasPrefix(normalizedToken) {
fieldScore = 200
} else {
fieldScore = 100
}
tokenScore = max(tokenScore ?? 0, fieldScore - min(index, 20))
}
guard let tokenScore else { return nil }
score += tokenScore
}

return score
}

private static func normalizedTokens(_ query: String) -> [String] {
query
.split(whereSeparator: \.isWhitespace)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}

private static func group(
id: String,
title: String,
items: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: id, title: title, items: items)]
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 378 additions & 0 deletions apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
import CoreGraphics
import Foundation

enum FeatureCommandPaletteAction: Hashable {
case newTask(projectID: String?)
case chooseNewTaskProject
case addProject
case settings
case openThread(id: String)
case openProject(id: String)
}

struct FeatureCommandPaletteItem: Identifiable, Equatable {
let id: String
let title: String
let detail: String?
let searchTerms: [String]
let systemImage: String
let action: FeatureCommandPaletteAction
let showsDisclosure: Bool

init(
id: String,
title: String,
detail: String? = nil,
searchTerms: [String] = [],
systemImage: String,
action: FeatureCommandPaletteAction,
showsDisclosure: Bool = false
) {
self.id = id
self.title = title
self.detail = detail
self.searchTerms = searchTerms
self.systemImage = systemImage
self.action = action
self.showsDisclosure = showsDisclosure
}
}

struct FeatureCommandPaletteGroup: Identifiable, Equatable {
let id: String
let title: String
let items: [FeatureCommandPaletteItem]
}

enum FeatureCommandPaletteMode: Equatable {
case root
case newTaskProjectPicker
}

enum FeatureCommandPaletteGesture {
static let maximumStartY: CGFloat = 88
static let minimumDownwardTranslation: CGFloat = 72

static func shouldPresent(startY: CGFloat, translation: CGSize) -> Bool {
guard startY <= maximumStartY,
translation.height >= minimumDownwardTranslation else {
return false
}

return translation.height > abs(translation.width) * 1.15
}
}

enum FeatureCommandPaletteCatalog {
static let recentThreadLimit = 12

static func groups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
activeProjectID: String?,
query: String
) -> [FeatureCommandPaletteGroup] {
let actions = actionItems(projects: projects, activeProjectID: activeProjectID)
let rawQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let actionsOnly = rawQuery.first == ">"
let searchQuery = actionsOnly
? String(rawQuery.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
: rawQuery

if searchQuery.isEmpty {
if actionsOnly {
return group(id: "actions", title: "Actions", items: actions)
}

return rootGroups(
actions: actions,
recentThreads: recentThreadItems(snapshot: snapshot)
)
}

let filteredActions = matchingItems(actions, query: searchQuery)
var groups: [FeatureCommandPaletteGroup] = []
if !filteredActions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: filteredActions
))
}

guard !actionsOnly else { return groups }

let filteredProjects = matchingItems(
projectItems(snapshot: snapshot, projects: projects),
query: searchQuery
)
if !filteredProjects.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "projects-search",
title: "Projects",
items: filteredProjects
))
}

let filteredThreads = matchingItems(
threadItems(snapshot: snapshot),
query: searchQuery
)
if !filteredThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "threads-search",
title: "Threads",
items: filteredThreads
))
}

return groups
}

static func newTaskProjectGroups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
query: String
) -> [FeatureCommandPaletteGroup] {
let items = matchingItems(
projectItems(
snapshot: snapshot,
projects: projects,
action: { .newTask(projectID: $0.id) },
idPrefix: "new-task-in"
),
query: query
)
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: "projects", title: "Projects", items: items)]
}

private static func rootGroups(
actions: [FeatureCommandPaletteItem],
recentThreads: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
var groups: [FeatureCommandPaletteGroup] = []
if !actions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: actions
))
}
if !recentThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "recent-threads",
title: "Recent Threads",
items: recentThreads
))
}
return groups
}

private static func actionItems(
projects: [FeatureProject],
activeProjectID: String?
) -> [FeatureCommandPaletteItem] {
var items: [FeatureCommandPaletteItem] = []
if !projects.isEmpty {
let activeProject = projects.first { $0.id == activeProjectID } ?? projects.first
if let activeProject {
items.append(FeatureCommandPaletteItem(
id: "action:new-task",
title: "New task in \(activeProject.name)",
detail: activeProject.path,
searchTerms: [
"new task", "new thread", "chat", "create", "draft",
activeProject.name, activeProject.path,
],
systemImage: "square.and.pencil",
action: .newTask(projectID: activeProject.id)
))
}

items.append(FeatureCommandPaletteItem(
id: "action:new-task-in",
title: "New task in…",
detail: "Choose a project",
searchTerms: ["new task", "new thread", "project", "choose", "select"],
systemImage: "square.and.pencil",
action: .chooseNewTaskProject,
showsDisclosure: true
))
}

items.append(FeatureCommandPaletteItem(
id: "action:add-project",
title: "Add project",
detail: "Local folder or remote repository",
searchTerms: [
"add project", "folder", "directory", "browse", "clone", "remote",
"repository", "repo", "git", "github", "gitlab", "bitbucket", "azure",
"devops", "url", "environment",
],
systemImage: "folder.badge.plus",
action: .addProject
))
items.append(FeatureCommandPaletteItem(
id: "action:settings",
title: "Open settings",
detail: "Preferences and connections",
searchTerms: ["settings", "preferences", "configuration", "connections"],
systemImage: "slider.horizontal.3",
action: .settings
))
return items
}

private static func recentThreadItems(
snapshot: FeatureSnapshot
) -> [FeatureCommandPaletteItem] {
Array(threadItems(snapshot: snapshot).prefix(recentThreadLimit))
}

private static func projectItems(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
action: (FeatureProject) -> FeatureCommandPaletteAction = { .openProject(id: $0.id) },
idPrefix: String = "project"
) -> [FeatureCommandPaletteItem] {
let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return projects
.sorted { left, right in
let nameOrder = left.name.localizedStandardCompare(right.name)
if nameOrder != .orderedSame { return nameOrder == .orderedAscending }
return left.id < right.id
}
.map { project in
let environment = environmentNames[project.environmentID]
let detail = [project.path, environment]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "\(idPrefix):\(project.id)",
title: project.name,
detail: detail.isEmpty ? nil : detail,
searchTerms: [project.name, project.path, environment ?? ""],
systemImage: "folder",
action: action(project)
)
}
}

private static func threadItems(snapshot: FeatureSnapshot) -> [FeatureCommandPaletteItem] {
let projectsByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HighWorkspace/FeatureCommandPaletteModel.swift:266

threadItems builds projectsByID with Dictionary(uniqueKeysWithValues:), which traps when the snapshot contains two projects with the same id. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped id, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. Dictionary(_:uniquingKeysWith:) or scoping by environment) instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift around line 266:
`threadItems` builds `projectsByID` with `Dictionary(uniqueKeysWithValues:)`, which traps when the snapshot contains two projects with the same `id`. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped `id`, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. `Dictionary(_:uniquingKeysWith:)` or scoping by environment) instead.

let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return snapshot.threads
.filter { !$0.isArchived }
.sorted { left, right in
let leftDate = left.lastActivityAt ?? left.updatedAt
let rightDate = right.lastActivityAt ?? right.updatedAt
if leftDate != rightDate { return leftDate > rightDate }
return left.id < right.id
}
.map { thread in
let project = projectsByID[thread.projectID]
let environment = thread.environmentID.flatMap { environmentNames[$0] }
?? thread.environmentName
let detail = [
project?.name,
thread.branch.map { "#\($0)" },
environment,
]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "thread:\(thread.id)",
title: thread.title,
detail: detail.isEmpty ? nil : detail,
searchTerms: [
thread.title,
thread.preview ?? "",
thread.branch ?? "",
project?.name ?? "",
project?.path ?? "",
environment ?? "",
],
systemImage: "message",
action: .openThread(id: thread.id)
)
}
}

private static func matchingItems(
_ items: [FeatureCommandPaletteItem],
query: String
) -> [FeatureCommandPaletteItem] {
let tokens = normalizedTokens(query)
guard !tokens.isEmpty else { return items }

return items.enumerated()
.compactMap { index, item -> (item: FeatureCommandPaletteItem, score: Int, index: Int)? in
guard let score = matchScore(item: item, tokens: tokens) else { return nil }
return (item, score, index)
}
.sorted { left, right in
if left.score != right.score { return left.score > right.score }
return left.index < right.index
}
.map(\.item)
}

private static func matchScore(
item: FeatureCommandPaletteItem,
tokens: [String]
) -> Int? {
let fields = [item.title, item.detail ?? ""] + item.searchTerms
var score = 0

for token in tokens {
var tokenScore: Int?
for (index, field) in fields.enumerated() {
guard field.localizedCaseInsensitiveContains(token) else { continue }
let normalizedField = field.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let normalizedToken = token.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let fieldScore: Int
if normalizedField == normalizedToken {
fieldScore = 300
} else if normalizedField.hasPrefix(normalizedToken) {
fieldScore = 200
} else {
fieldScore = 100
}
tokenScore = max(tokenScore ?? 0, fieldScore - min(index, 20))
}
guard let tokenScore else { return nil }
score += tokenScore
}

return score
}

private static func normalizedTokens(_ query: String) -> [String] {
query
.split(whereSeparator: \.isWhitespace)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}

private static func group(
id: String,
title: String,
items: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: id, title: title, items: items)]
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 378 additions & 0 deletions apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
import CoreGraphics
import Foundation

enum FeatureCommandPaletteAction: Hashable {
case newTask(projectID: String?)
case chooseNewTaskProject
case addProject
case settings
case openThread(id: String)
case openProject(id: String)
}

struct FeatureCommandPaletteItem: Identifiable, Equatable {
let id: String
let title: String
let detail: String?
let searchTerms: [String]
let systemImage: String
let action: FeatureCommandPaletteAction
let showsDisclosure: Bool

init(
id: String,
title: String,
detail: String? = nil,
searchTerms: [String] = [],
systemImage: String,
action: FeatureCommandPaletteAction,
showsDisclosure: Bool = false
) {
self.id = id
self.title = title
self.detail = detail
self.searchTerms = searchTerms
self.systemImage = systemImage
self.action = action
self.showsDisclosure = showsDisclosure
}
}

struct FeatureCommandPaletteGroup: Identifiable, Equatable {
let id: String
let title: String
let items: [FeatureCommandPaletteItem]
}

enum FeatureCommandPaletteMode: Equatable {
case root
case newTaskProjectPicker
}

enum FeatureCommandPaletteGesture {
static let maximumStartY: CGFloat = 88
static let minimumDownwardTranslation: CGFloat = 72

static func shouldPresent(startY: CGFloat, translation: CGSize) -> Bool {
guard startY <= maximumStartY,
translation.height >= minimumDownwardTranslation else {
return false
}

return translation.height > abs(translation.width) * 1.15
}
}

enum FeatureCommandPaletteCatalog {
static let recentThreadLimit = 12

static func groups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
activeProjectID: String?,
query: String
) -> [FeatureCommandPaletteGroup] {
let actions = actionItems(projects: projects, activeProjectID: activeProjectID)
let rawQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let actionsOnly = rawQuery.first == ">"
let searchQuery = actionsOnly
? String(rawQuery.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
: rawQuery

if searchQuery.isEmpty {
if actionsOnly {
return group(id: "actions", title: "Actions", items: actions)
}

return rootGroups(
actions: actions,
recentThreads: recentThreadItems(snapshot: snapshot)
)
}

let filteredActions = matchingItems(actions, query: searchQuery)
var groups: [FeatureCommandPaletteGroup] = []
if !filteredActions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: filteredActions
))
}

guard !actionsOnly else { return groups }

let filteredProjects = matchingItems(
projectItems(snapshot: snapshot, projects: projects),
query: searchQuery
)
if !filteredProjects.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "projects-search",
title: "Projects",
items: filteredProjects
))
}

let filteredThreads = matchingItems(
threadItems(snapshot: snapshot),
query: searchQuery
)
if !filteredThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "threads-search",
title: "Threads",
items: filteredThreads
))
}

return groups
}

static func newTaskProjectGroups(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
query: String
) -> [FeatureCommandPaletteGroup] {
let items = matchingItems(
projectItems(
snapshot: snapshot,
projects: projects,
action: { .newTask(projectID: $0.id) },
idPrefix: "new-task-in"
),
query: query
)
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: "projects", title: "Projects", items: items)]
}

private static func rootGroups(
actions: [FeatureCommandPaletteItem],
recentThreads: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
var groups: [FeatureCommandPaletteGroup] = []
if !actions.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "actions",
title: "Actions",
items: actions
))
}
if !recentThreads.isEmpty {
groups.append(FeatureCommandPaletteGroup(
id: "recent-threads",
title: "Recent Threads",
items: recentThreads
))
}
return groups
}

private static func actionItems(
projects: [FeatureProject],
activeProjectID: String?
) -> [FeatureCommandPaletteItem] {
var items: [FeatureCommandPaletteItem] = []
if !projects.isEmpty {
let activeProject = projects.first { $0.id == activeProjectID } ?? projects.first
if let activeProject {
items.append(FeatureCommandPaletteItem(
id: "action:new-task",
title: "New task in \(activeProject.name)",
detail: activeProject.path,
searchTerms: [
"new task", "new thread", "chat", "create", "draft",
activeProject.name, activeProject.path,
],
systemImage: "square.and.pencil",
action: .newTask(projectID: activeProject.id)
))
}

items.append(FeatureCommandPaletteItem(
id: "action:new-task-in",
title: "New task in…",
detail: "Choose a project",
searchTerms: ["new task", "new thread", "project", "choose", "select"],
systemImage: "square.and.pencil",
action: .chooseNewTaskProject,
showsDisclosure: true
))
}

items.append(FeatureCommandPaletteItem(
id: "action:add-project",
title: "Add project",
detail: "Local folder or remote repository",
searchTerms: [
"add project", "folder", "directory", "browse", "clone", "remote",
"repository", "repo", "git", "github", "gitlab", "bitbucket", "azure",
"devops", "url", "environment",
],
systemImage: "folder.badge.plus",
action: .addProject
))
items.append(FeatureCommandPaletteItem(
id: "action:settings",
title: "Open settings",
detail: "Preferences and connections",
searchTerms: ["settings", "preferences", "configuration", "connections"],
systemImage: "slider.horizontal.3",
action: .settings
))
return items
}

private static func recentThreadItems(
snapshot: FeatureSnapshot
) -> [FeatureCommandPaletteItem] {
Array(threadItems(snapshot: snapshot).prefix(recentThreadLimit))
}

private static func projectItems(
snapshot: FeatureSnapshot,
projects: [FeatureProject],
action: (FeatureProject) -> FeatureCommandPaletteAction = { .openProject(id: $0.id) },
idPrefix: String = "project"
) -> [FeatureCommandPaletteItem] {
let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return projects
.sorted { left, right in
let nameOrder = left.name.localizedStandardCompare(right.name)
if nameOrder != .orderedSame { return nameOrder == .orderedAscending }
return left.id < right.id
}
.map { project in
let environment = environmentNames[project.environmentID]
let detail = [project.path, environment]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "\(idPrefix):\(project.id)",
title: project.name,
detail: detail.isEmpty ? nil : detail,
searchTerms: [project.name, project.path, environment ?? ""],
systemImage: "folder",
action: action(project)
)
}
}

private static func threadItems(snapshot: FeatureSnapshot) -> [FeatureCommandPaletteItem] {
let projectsByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HighWorkspace/FeatureCommandPaletteModel.swift:266

threadItems builds projectsByID with Dictionary(uniqueKeysWithValues:), which traps when the snapshot contains two projects with the same id. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped id, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. Dictionary(_:uniquingKeysWith:) or scoping by environment) instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Workspace/FeatureCommandPaletteModel.swift around line 266:
`threadItems` builds `projectsByID` with `Dictionary(uniqueKeysWithValues:)`, which traps when the snapshot contains two projects with the same `id`. Legacy aggregate snapshots can include cloned projects from multiple environments sharing an unscoped `id`, so presenting or searching the command palette with such a cached snapshot crashes the app. Consider using a duplicate-tolerant lookup (e.g. `Dictionary(_:uniquingKeysWith:)` or scoping by environment) instead.

let environmentNames = Dictionary(
uniqueKeysWithValues: snapshot.environments.map { ($0.id, $0.name) }
)

return snapshot.threads
.filter { !$0.isArchived }
.sorted { left, right in
let leftDate = left.lastActivityAt ?? left.updatedAt
let rightDate = right.lastActivityAt ?? right.updatedAt
if leftDate != rightDate { return leftDate > rightDate }
return left.id < right.id
}
.map { thread in
let project = projectsByID[thread.projectID]
let environment = thread.environmentID.flatMap { environmentNames[$0] }
?? thread.environmentName
let detail = [
project?.name,
thread.branch.map { "#\($0)" },
environment,
]
.compactMap { $0?.isEmpty == false ? $0 : nil }
.joined(separator: " · ")
return FeatureCommandPaletteItem(
id: "thread:\(thread.id)",
title: thread.title,
detail: detail.isEmpty ? nil : detail,
searchTerms: [
thread.title,
thread.preview ?? "",
thread.branch ?? "",
project?.name ?? "",
project?.path ?? "",
environment ?? "",
],
systemImage: "message",
action: .openThread(id: thread.id)
)
}
}

private static func matchingItems(
_ items: [FeatureCommandPaletteItem],
query: String
) -> [FeatureCommandPaletteItem] {
let tokens = normalizedTokens(query)
guard !tokens.isEmpty else { return items }

return items.enumerated()
.compactMap { index, item -> (item: FeatureCommandPaletteItem, score: Int, index: Int)? in
guard let score = matchScore(item: item, tokens: tokens) else { return nil }
return (item, score, index)
}
.sorted { left, right in
if left.score != right.score { return left.score > right.score }
return left.index < right.index
}
.map(\.item)
}

private static func matchScore(
item: FeatureCommandPaletteItem,
tokens: [String]
) -> Int? {
let fields = [item.title, item.detail ?? ""] + item.searchTerms
var score = 0

for token in tokens {
var tokenScore: Int?
for (index, field) in fields.enumerated() {
guard field.localizedCaseInsensitiveContains(token) else { continue }
let normalizedField = field.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let normalizedToken = token.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
let fieldScore: Int
if normalizedField == normalizedToken {
fieldScore = 300
} else if normalizedField.hasPrefix(normalizedToken) {
fieldScore = 200
} else {
fieldScore = 100
}
tokenScore = max(tokenScore ?? 0, fieldScore - min(index, 20))
}
guard let tokenScore else { return nil }
score += tokenScore
}

return score
}

private static func normalizedTokens(_ query: String) -> [String] {
query
.split(whereSeparator: \.isWhitespace)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}

private static func group(
id: String,
title: String,
items: [FeatureCommandPaletteItem]
) -> [FeatureCommandPaletteGroup] {
guard !items.isEmpty else { return [] }
return [FeatureCommandPaletteGroup(id: id, title: title, items: items)]
}
}
Loading
Loading