From d6ef7b558a80131377344616d4e58e182fee543a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 21:29:56 -0700 Subject: [PATCH 1/6] feat(sidebar): revive workspace icons --- CLI/cmux.swift | 25 +- Resources/Localizable.xcstrings | 15 +- Sources/CmuxSettingsJSONPathSupport.swift | 1 + Sources/ContentView.swift | 83 +++++- ...rdShortcutSettingsFileStore+Template.swift | 1 + .../KeyboardShortcutSettingsFileStore.swift | 5 + Sources/SessionPersistence.swift | 1 + Sources/SettingsNavigation.swift | 2 + Sources/SettingsSearchAliases.swift | 1 + .../Sidebar/SidebarAppearanceSupport.swift | 239 ++++++++++++++++++ ...idebarWorkspaceSnapshotRefreshPolicy.swift | 5 +- Sources/TabManager.swift | 12 + Sources/TerminalController.swift | 22 +- Sources/Workspace.swift | 71 +++++- Sources/cmuxApp.swift | 15 ++ ...rWorkspaceSnapshotRefreshPolicyTests.swift | 28 ++ cmuxTests/WorkspaceUnitTests.swift | 44 ++++ 17 files changed, 561 insertions(+), 9 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2544b3d8d1a5..d3f3c7a2037e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4617,9 +4617,10 @@ struct CMUXCLI { let (actionOpt, rem1) = parseOption(rem0, name: "--action") let (titleOpt, rem2) = parseOption(rem1, name: "--title") let (colorOpt, rem3) = parseOption(rem2, name: "--color") - let (descriptionOpt, rem4) = parseOption(rem3, name: "--description") + let (iconOpt, rem4) = parseOption(rem3, name: "--icon") + let (descriptionOpt, rem5) = parseOption(rem4, name: "--description") - var positional = rem4 + var positional = rem5 let actionRaw: String if let actionOpt { actionRaw = actionOpt @@ -4653,6 +4654,13 @@ struct CMUXCLI { throw CLIError(message: "workspace-action set-color requires --color (or a trailing color)") } + let icon = ( + iconOpt ?? (action == "set_icon" ? (inferredPositional.isEmpty ? nil : inferredPositional) : nil) + )?.trimmingCharacters(in: .whitespacesAndNewlines) + if action == "set_icon", (icon?.isEmpty ?? true) { + throw CLIError(message: "workspace-action set-icon requires --icon (or a trailing icon)") + } + let description = ( descriptionOpt ?? (action == "set_description" && !inferredPositional.isEmpty ? inferredPositionalRaw : nil) )?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -4670,6 +4678,9 @@ struct CMUXCLI { if let color, !color.isEmpty { params["color"] = color } + if let icon, !icon.isEmpty { + params["icon"] = icon + } if let description, !description.isEmpty { params["description"] = description } @@ -4691,6 +4702,9 @@ struct CMUXCLI { if let color = payload["color"] as? String { summaryParts.append("color=\(color)") } + if let icon = payload["custom_icon"] as? String { + summaryParts.append("icon=\(icon)") + } printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } @@ -9509,12 +9523,14 @@ struct CMUXCLI { close-others | close-above | close-below mark-read | mark-unread set-color | clear-color + set-icon | clear-icon Flags: --action Action name (required if not positional) --workspace Target workspace (default: current/$CMUX_WORKSPACE_ID) --title Title for rename --color Color for set-color (name or #RRGGBB hex) + --icon Icon for set-icon --description Description for set-description Named colors: @@ -9528,9 +9544,12 @@ struct CMUXCLI { cmux workspace-action --action set-color --color blue cmux workspace-action --action set-color --color "#C0392B" cmux workspace-action set-color Amber + cmux workspace-action --action set-icon --icon emoji:🚀 + cmux workspace-action set-icon ~/Desktop/icon.png cmux workspace-action --action set-description --description "Ship checklist" cmux workspace-action --action set-description $'Ship checklist\n- verify build\n- post notes' cmux workspace-action clear-color + cmux workspace-action clear-icon """ case "tab-action": return """ @@ -24200,7 +24219,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { close-window --window move-workspace-to-window --workspace --window reorder-workspace --workspace (--index | --before | --after ) [--window ] - workspace-action --action [--workspace ] [--title ] [--color ] [--description ] + workspace-action --action [--workspace ] [--title ] [--color ] [--icon ] [--description ] move-tab-to-new-workspace [--tab ] [--surface ] [--workspace ] [--title ] [--focus ] list-workspaces new-workspace [--name ] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index deec5a0f30ef..a2cf1c1ba931 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -112816,6 +112816,19 @@ "rightSidebar.remote.error.modeUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Right sidebar mode '%@' is not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 右サイドバーのモード '%@' は利用できません" } } } }, "rightSidebar.remote.error.appDelegateUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: App delegate not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: アプリデリゲートを利用できません" } } } }, "notificationHook.failure.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Notification Hook Failed" } }, "ja": { "stringUnit": { "state": "translated", "value": "通知フックに失敗しました" } } } }, - "notificationHook.failure.body": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "cmux used default notification behavior because '%@' failed." } }, "ja": { "stringUnit": { "state": "translated", "value": "'%@' が失敗したため、cmux は既定の通知動作を使用しました。" } } } } + "notificationHook.failure.body": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "cmux used default notification behavior because '%@' failed." } }, "ja": { "stringUnit": { "state": "translated", "value": "'%@' が失敗したため、cmux は既定の通知動作を使用しました。" } } } }, + "alert.emojiIcon.apply": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Apply" } }, "ja": { "stringUnit": { "state": "translated", "value": "適用" } } } }, + "alert.emojiIcon.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Cancel" } }, "ja": { "stringUnit": { "state": "translated", "value": "キャンセル" } } } }, + "alert.emojiIcon.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enter an emoji to use as the workspace icon." } }, "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースアイコンとして使う絵文字を入力してください。" } } } }, + "alert.emojiIcon.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "🚀" } }, "ja": { "stringUnit": { "state": "translated", "value": "🚀" } } } }, + "alert.emojiIcon.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Emoji Workspace Icon" } }, "ja": { "stringUnit": { "state": "translated", "value": "絵文字ワークスペースアイコン" } } } }, + "contextMenu.clearIcon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Clear Icon" } }, "ja": { "stringUnit": { "state": "translated", "value": "アイコンをクリア" } } } }, + "contextMenu.setEmojiIcon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Set Emoji Icon…" } }, "ja": { "stringUnit": { "state": "translated", "value": "絵文字アイコンを設定…" } } } }, + "contextMenu.setIconFromFile": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Set Icon from File…" } }, "ja": { "stringUnit": { "state": "translated", "value": "ファイルからアイコンを設定…" } } } }, + "contextMenu.workspaceIcon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Workspace Icon" } }, "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースアイコン" } } } }, + "openPanel.workspaceIcon.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose Workspace Icon" } }, "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースアイコンを選択" } } } }, + "settings.app.autoDetectWorkspaceIcon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Auto-Detect Workspace Icons" } }, "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースアイコンを自動検出" } } } }, + "settings.app.autoDetectWorkspaceIcon.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Use favicon, app icon, or logo files from each workspace directory when no custom icon is set." } }, "ja": { "stringUnit": { "state": "translated", "value": "カスタムアイコンがない場合に、各ワークスペースディレクトリの favicon、アプリアイコン、ロゴファイルを使います。" } } } }, + "settings.search.alias.setting.app.auto-detect-workspace-icon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar" } }, "ja": { "stringUnit": { "state": "translated", "value": "sidebar.autoDetectWorkspaceIcon favicon アイコン ロゴ アプリアイコン ワークスペース アバター" } } } } } } diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index e5b78a44a84a..da8c0fe773ba 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -75,6 +75,7 @@ extension CmuxSettingsFileStore { "sidebar.hideAllDetails", "sidebar.showWorkspaceDescription", "sidebar.branchLayout", + "sidebar.autoDetectWorkspaceIcon", "sidebar.showNotificationMessage", "sidebar.showBranchDirectory", "sidebar.showPullRequests", diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b42ff91c488f..fc9ecbf7a12d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9245,6 +9245,7 @@ private struct SidebarTabItemSettingsSnapshot: Equatable { let notificationBadgeColorHex: String? let visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility let iMessageModeEnabled: Bool + let autoDetectWorkspaceIcon: Bool init(defaults: UserDefaults = .standard) { sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX @@ -9298,6 +9299,7 @@ private struct SidebarTabItemSettingsSnapshot: Equatable { selectionColorHex = defaults.string(forKey: "sidebarSelectionColorHex") notificationBadgeColorHex = defaults.string(forKey: "sidebarNotificationBadgeColorHex") iMessageModeEnabled = IMessageModeSettings.isEnabled(defaults: defaults) + autoDetectWorkspaceIcon = SidebarWorkspaceIconSettings.autoDetectsWorkspaceIcon(defaults: defaults) } private static func bool( @@ -12276,6 +12278,7 @@ struct SidebarWorkspaceSnapshotBuilder { let customDescription: String? let isPinned: Bool let customColorHex: String? + let workspaceIconPath: String? let remoteWorkspaceSidebarText: String? let remoteConnectionStatusText: String let remoteStateHelpText: String @@ -12624,6 +12627,14 @@ private struct TabItemView: View, Equatable { : nil let effectiveSubtitle = latestNotificationSubtitle ?? conversationMessageSubtitle let detailVisibility = visibleAuxiliaryDetails + let workspaceIconPath = workspaceSnapshot.workspaceIconPath + + HStack(alignment: .top, spacing: workspaceIconPath == nil ? 0 : 10) { + if let workspaceIconPath { + WorkspaceIconView(iconPath: workspaceIconPath, size: 36) + .padding(.top, 1) + .accessibilityIdentifier("SidebarWorkspaceIcon") + } VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { @@ -12834,10 +12845,13 @@ private struct TabItemView: View, Equatable { .lineLimit(1) } } + .frame(maxWidth: .infinity, alignment: .leading) + } .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.latestLog) .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.progress != nil) .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.metadataBlocks.count) - .padding(.horizontal, 10) + .padding(.leading, workspaceIconPath == nil ? 10 : 6) + .padding(.trailing, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 6) @@ -12921,6 +12935,7 @@ private struct TabItemView: View, Equatable { } } .onAppear { + refreshWorkspaceIconDetection() refreshWorkspaceSnapshot(force: true) } .task(id: finderDirectoryCacheKey) { @@ -12948,6 +12963,7 @@ private struct TabItemView: View, Equatable { "desc=\"\(debugCommandPaletteTextPreview(description))\"" ) #endif + refreshWorkspaceIconDetection() refreshWorkspaceSnapshot() } .onReceive( @@ -12968,9 +12984,11 @@ private struct TabItemView: View, Equatable { "desc=\"\(debugCommandPaletteTextPreview(description))\"" ) #endif + refreshWorkspaceIconDetection() refreshWorkspaceSnapshot() } .onChange(of: settings) { _ in + refreshWorkspaceIconDetection() refreshWorkspaceSnapshot(force: true) } .onDrag { @@ -13047,6 +13065,10 @@ private struct TabItemView: View, Equatable { } } + private func refreshWorkspaceIconDetection() { + tab.refreshDetectedWorkspaceIcon(autoDetectEnabled: settings.autoDetectWorkspaceIcon) + } + private func flushDeferredWorkspaceObservationInvalidation() { guard contextMenuState.hasDeferredWorkspaceObservationInvalidation else { return } contextMenuState.hasDeferredWorkspaceObservationInvalidation = false @@ -13199,6 +13221,28 @@ private struct TabItemView: View, Equatable { } } + Menu(String(localized: "contextMenu.workspaceIcon", defaultValue: "Workspace Icon")) { + if tab.customIconPath != nil { + Button { + applyTabIcon(nil, targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.clearIcon", defaultValue: "Clear Icon"), systemImage: "xmark.circle") + } + } + + Button { + promptIconFromFile(targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.setIconFromFile", defaultValue: "Set Icon from File…"), systemImage: "photo") + } + + Button { + promptEmojiIcon(targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.setEmojiIcon", defaultValue: "Set Emoji Icon…"), systemImage: "face.smiling") + } + } + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { if tab.customColor != nil { Button { @@ -13619,6 +13663,7 @@ private struct TabItemView: View, Equatable { customDescription: settings.showsWorkspaceDescription ? sidebarVisibleCustomDescription : nil, isPinned: tab.isPinned, customColorHex: tab.customColor, + workspaceIconPath: tab.effectiveIconPath(autoDetectEnabled: settings.autoDetectWorkspaceIcon), remoteWorkspaceSidebarText: remoteWorkspaceSidebarText, remoteConnectionStatusText: remoteConnectionStatusText, remoteStateHelpText: remoteStateHelpText, @@ -13956,6 +14001,10 @@ private struct TabItemView: View, Equatable { tabManager.applyWorkspaceColor(hex, toWorkspaceIds: targetIds) } + private func applyTabIcon(_ iconPath: String?, targetIds: [UUID]) { + tabManager.applyWorkspaceIcon(iconPath, toWorkspaceIds: targetIds) + } + private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID]) { let currentlyHidden = !targetIds.isEmpty && targetIds.allSatisfy { targetId in tabManager.tabs.first(where: { $0.id == targetId })?.terminalScrollBarHidden == true @@ -13966,6 +14015,38 @@ private struct TabItemView: View, Equatable { } } + private func promptIconFromFile(targetIds: [UUID]) { + let panel = NSOpenPanel() + panel.title = String(localized: "openPanel.workspaceIcon.title", defaultValue: "Choose Workspace Icon") + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + let response = panel.runModal() + guard response == .OK, let url = panel.url else { return } + applyTabIcon(url.path, targetIds: targetIds) + } + + private func promptEmojiIcon(targetIds: [UUID]) { + let alert = NSAlert() + alert.messageText = String(localized: "alert.emojiIcon.title", defaultValue: "Emoji Workspace Icon") + alert.informativeText = String(localized: "alert.emojiIcon.message", defaultValue: "Enter an emoji to use as the workspace icon.") + + let input = NSTextField(string: "") + input.placeholderString = String(localized: "alert.emojiIcon.placeholder", defaultValue: "🚀") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.emojiIcon.apply", defaultValue: "Apply")) + alert.addButton(withTitle: String(localized: "alert.emojiIcon.cancel", defaultValue: "Cancel")) + alert.window.initialFirstResponder = input + + let response = alert.runModal() + guard response == .alertFirstButtonReturn, + let normalized = WorkspaceIconValue.normalizedStorageValue("emoji:\(input.stringValue)") else { + return + } + applyTabIcon(normalized, targetIds: targetIds) + } + private func promptCustomColor(targetIds: [UUID]) { let alert = NSAlert() alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 32e82d9834cd..08afaeedc820 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -101,6 +101,7 @@ extension CmuxSettingsFileStore { "hideAllDetails": SidebarWorkspaceDetailSettings.defaultHideAllDetails, "showWorkspaceDescription": SidebarWorkspaceDetailSettings.defaultShowWorkspaceDescription, "branchLayout": SidebarBranchLayoutSettings.defaultVerticalLayout ? "vertical" : "inline", + "autoDetectWorkspaceIcon": SidebarWorkspaceIconSettings.defaultAutoDetect, "showNotificationMessage": SidebarWorkspaceDetailSettings.defaultShowNotificationMessage, "showBranchDirectory": SidebarWorkspaceDetailDefaults.showBranchDirectory, "showPullRequests": SidebarWorkspaceDetailDefaults.showPullRequests, diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 12039da44a6d..66e8823ccb98 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -493,6 +493,11 @@ final class CmuxSettingsFileStore { logInvalid("sidebar.branchLayout", sourcePath: sourcePath) } } + if let value = jsonBool(section["autoDetectWorkspaceIcon"]) { + snapshot.managedUserDefaults[SidebarWorkspaceIconSettings.autoDetectKey] = .bool(value) + } else if section.keys.contains("autoDetectWorkspaceIcon") { + logInvalid("sidebar.autoDetectWorkspaceIcon", sourcePath: sourcePath) + } if let value = jsonBool(section["showNotificationMessage"]) { snapshot.managedUserDefaults[SidebarWorkspaceDetailSettings.showNotificationMessageKey] = .bool(value) } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 5f28ea798ace..afed130a20e8 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -343,6 +343,7 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var customTitle: String? var customDescription: String? var customColor: String? + var customIconPath: String? var isPinned: Bool var isManuallyUnread: Bool? = nil var hasUnreadIndicator: Bool? = nil diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index cb316936debb..bd9f6776004e 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -323,6 +323,7 @@ enum SettingsSearchIndex { setting(.sidebarAppearance, "hide-sidebar-details", String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), "workspace sidebar compact"), setting(.sidebarAppearance, "show-workspace-description", String(localized: "settings.app.showWorkspaceDescription", defaultValue: "Show Workspace Description in Sidebar"), "workspace description notes markdown"), setting(.sidebarAppearance, "sidebar-branch-layout", String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"), "branch directory vertical inline"), + setting(.sidebarAppearance, "auto-detect-workspace-icon", String(localized: "settings.app.autoDetectWorkspaceIcon", defaultValue: "Auto-Detect Workspace Icons"), "favicon icon logo app icon workspace avatar"), setting(.sidebarAppearance, "show-notification-message", String(localized: "settings.app.showNotificationMessage", defaultValue: "Show Notification Message in Sidebar"), "workspace latest notification"), setting(.sidebarAppearance, "show-branch-directory", String(localized: "settings.app.showBranchDirectory", defaultValue: "Show Branch + Directory in Sidebar"), "git cwd path"), setting(.sidebarAppearance, "show-pull-requests", String(localized: "settings.app.showPullRequests", defaultValue: "Show Pull Requests in Sidebar"), "review pr mr link"), @@ -411,6 +412,7 @@ enum SettingsSearchIndex { "sidebar.hideAllDetails": settingID(for: .sidebarAppearance, idSuffix: "hide-sidebar-details"), "sidebar.showWorkspaceDescription": settingID(for: .sidebarAppearance, idSuffix: "show-workspace-description"), "sidebar.branchLayout": settingID(for: .sidebarAppearance, idSuffix: "sidebar-branch-layout"), + "sidebar.autoDetectWorkspaceIcon": settingID(for: .sidebarAppearance, idSuffix: "auto-detect-workspace-icon"), "sidebar.showNotificationMessage": settingID(for: .sidebarAppearance, idSuffix: "show-notification-message"), "sidebar.showBranchDirectory": settingID(for: .sidebarAppearance, idSuffix: "show-branch-directory"), "sidebar.showPullRequests": settingID(for: .sidebarAppearance, idSuffix: "show-pull-requests"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 2ed7d4fc2138..62fbb32abab2 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -72,6 +72,7 @@ enum SettingsSearchAliasIndex { "sidebarAppearance:hide-sidebar-details": localized("settings.search.alias.setting.app.hide-sidebar-details", defaultValue: "sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"), "sidebarAppearance:show-workspace-description": localized("settings.search.alias.setting.app.show-workspace-description", defaultValue: "sidebar.showWorkspaceDescription workspace description notes markdown sidebar"), "sidebarAppearance:sidebar-branch-layout": localized("settings.search.alias.setting.app.sidebar-branch-layout", defaultValue: "sidebar.branchLayout git branch layout vertical inline cwd directory"), + "sidebarAppearance:auto-detect-workspace-icon": localized("settings.search.alias.setting.app.auto-detect-workspace-icon", defaultValue: "sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"), "sidebarAppearance:show-notification-message": localized("settings.search.alias.setting.app.show-notification-message", defaultValue: "sidebar.showNotificationMessage latest message unread notification text sidebar"), "sidebarAppearance:show-branch-directory": localized("settings.search.alias.setting.app.show-branch-directory", defaultValue: "sidebar.showBranchDirectory git branch cwd path directory folder repo sidebar"), "sidebarAppearance:show-pull-requests": localized("settings.search.alias.setting.app.show-pull-requests", defaultValue: "sidebar.showPullRequests pr mr review github gitlab bitbucket pull request merge request"), diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 1cb7b0942e9f..95c9a88e1b26 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -7,6 +7,245 @@ enum SidebarMatchTerminalBackgroundSettings { static let legacyAppliedSettingsFileDefaultKey = "cmux.settingsFile.sidebarMatchTerminalBackground.appliedDefault.v1" } +enum SidebarWorkspaceIconSettings { + static let autoDetectKey = "sidebarAutoDetectWorkspaceIcon" + static let defaultAutoDetect = false + + static func autoDetectsWorkspaceIcon(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: autoDetectKey) != nil else { return defaultAutoDetect } + return defaults.bool(forKey: autoDetectKey) + } +} + +enum WorkspaceIconValue: Equatable, Sendable { + case emoji(String) + case file(path: String) + + init?(storedValue: String?) { + guard let storedValue = Self.normalizedStorageValue(storedValue) else { return nil } + if storedValue.hasPrefix(Self.emojiPrefix) { + let emoji = String(storedValue.dropFirst(Self.emojiPrefix.count)) + guard !emoji.isEmpty else { return nil } + self = .emoji(emoji) + } else { + self = .file(path: storedValue) + } + } + + var storageValue: String { + switch self { + case .emoji(let emoji): + return Self.emojiPrefix + emoji + case .file(let path): + return path + } + } + + static func normalizedStorageValue(_ rawValue: String?) -> String? { + guard let rawValue else { return nil } + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix(Self.emojiPrefix) { + let emoji = String(trimmed.dropFirst(Self.emojiPrefix.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return emoji.isEmpty ? nil : Self.emojiPrefix + emoji + } + + let expanded = NSString(string: trimmed).expandingTildeInPath + let standardized = NSString(string: expanded).standardizingPath + let normalized = standardized.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + + private static let emojiPrefix = "emoji:" +} + +enum WorkspaceIconDetector { + static let standardIconFilenames = [ + "favicon.png", "favicon.ico", "favicon.svg", + "icon.png", "icon.svg", + "logo.png", "logo.svg", + ] + + static let standardIconSubdirectories = [ + "", + "public", + "static", + "src", + "assets", + "images", + "Resources", + ".github", + "docs", + ] + + static func detectedIconPath(in directory: String) -> String? { + let normalizedDirectory = WorkspaceIconValue.normalizedStorageValue(directory) + guard let normalizedDirectory else { return nil } + + let fileManager = FileManager.default + if let standardIcon = findStandardIcon(in: normalizedDirectory, fileManager: fileManager) { + return standardIcon + } + if let appIcon = findXcodeAppIcon(in: normalizedDirectory, fileManager: fileManager) { + return appIcon + } + if let androidIcon = findFirstExistingPath( + in: normalizedDirectory, + relativePaths: [ + "android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png", + "android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png", + "app/src/main/res/mipmap-xxxhdpi/ic_launcher.png", + "app/src/main/res/mipmap-xxhdpi/ic_launcher.png", + ], + fileManager: fileManager + ) { + return androidIcon + } + return findFirstExistingPath( + in: normalizedDirectory, + relativePaths: [ + "build/icon.png", + "build/icons/icon.png", + "resources/icon.png", + ], + fileManager: fileManager + ) + } + + private static func findStandardIcon(in directory: String, fileManager: FileManager) -> String? { + for subdirectory in standardIconSubdirectories { + let basePath = subdirectory.isEmpty + ? directory + : (directory as NSString).appendingPathComponent(subdirectory) + for filename in standardIconFilenames { + let path = (basePath as NSString).appendingPathComponent(filename) + if fileManager.fileExists(atPath: path) { + return path + } + } + } + return nil + } + + private static func findXcodeAppIcon(in directory: String, fileManager: FileManager) -> String? { + for candidate in [ + "Assets.xcassets/AppIcon.appiconset", + "Resources/Assets.xcassets/AppIcon.appiconset", + ] { + let iconsetPath = (directory as NSString).appendingPathComponent(candidate) + guard let contents = try? fileManager.contentsOfDirectory(atPath: iconsetPath) else { + continue + } + let pngs = contents + .filter { $0.lowercased().hasSuffix(".png") } + .sorted { lhs, rhs in lhs.localizedStandardCompare(rhs) == .orderedDescending } + if let largest = pngs.first { + return (iconsetPath as NSString).appendingPathComponent(largest) + } + } + return nil + } + + private static func findFirstExistingPath( + in directory: String, + relativePaths: [String], + fileManager: FileManager + ) -> String? { + for relativePath in relativePaths { + let path = (directory as NSString).appendingPathComponent(relativePath) + if fileManager.fileExists(atPath: path) { + return path + } + } + return nil + } +} + +private final class WorkspaceIconImageBox: @unchecked Sendable { + let image: NSImage + + init(image: NSImage) { + self.image = image + } +} + +struct WorkspaceIconView: View { + let iconPath: String + let size: CGFloat + + @State private var loadedImage: NSImage? + @State private var loadedImagePath: String? + @State private var imageLoadFailed = false + + private var iconValue: WorkspaceIconValue? { + WorkspaceIconValue(storedValue: iconPath) + } + + var body: some View { + ZStack { + switch iconValue { + case .emoji(let emoji): + Text(emoji) + .font(.system(size: size * 0.55)) + .frame(width: size, height: size) + .background(Circle().fill(Color.primary.opacity(0.08))) + + case .file(let path): + if loadedImagePath == path, let loadedImage { + Image(nsImage: loadedImage) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + } else { + placeholderIcon + } + + case nil: + placeholderIcon + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 0.5)) + .task(id: iconPath) { + await loadFileIconIfNeeded() + } + } + + @ViewBuilder + private var placeholderIcon: some View { + Image(systemName: imageLoadFailed ? "exclamationmark.triangle" : "photo") + .font(.system(size: size * 0.36, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: size, height: size) + .background(Circle().fill(Color.primary.opacity(0.06))) + } + + @MainActor + private func loadFileIconIfNeeded() async { + guard case .file(let path) = iconValue else { + loadedImage = nil + loadedImagePath = nil + imageLoadFailed = false + return + } + + loadedImage = nil + loadedImagePath = path + imageLoadFailed = false + + let imageBox = await Task.detached(priority: .utility) { + NSImage(contentsOfFile: path).map(WorkspaceIconImageBox.init(image:)) + }.value + + guard !Task.isCancelled, loadedImagePath == path else { return } + loadedImage = imageBox?.image + imageLoadFailed = imageBox == nil + } +} + extension Color { init?(hex: String) { let hex = hex.trimmingCharacters(in: .init(charactersIn: "#")) diff --git a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift index 9122a7921b3c..ef90226d2838 100644 --- a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift +++ b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift @@ -4,6 +4,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { let customDescription: String? let isPinned: Bool let customColorHex: String? + let workspaceIconPath: String? } var contextMenuImmediateFields: ContextMenuImmediateFields { @@ -11,7 +12,8 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { title: title, customDescription: customDescription, isPinned: isPinned, - customColorHex: customColorHex + customColorHex: customColorHex, + workspaceIconPath: workspaceIconPath ) } @@ -23,6 +25,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { customDescription: snapshot.customDescription, isPinned: snapshot.isPinned, customColorHex: snapshot.customColorHex, + workspaceIconPath: snapshot.workspaceIconPath, remoteWorkspaceSidebarText: remoteWorkspaceSidebarText, remoteConnectionStatusText: remoteConnectionStatusText, remoteStateHelpText: remoteStateHelpText, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 7ad39123fd8d..3dbbe3ae27b3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3985,6 +3985,17 @@ class TabManager: ObservableObject { tab.setCustomColor(color) } + func setTabIcon(tabId: UUID, iconPath: String?) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.setCustomIcon(iconPath) + } + + func applyWorkspaceIcon(_ iconPath: String?, toWorkspaceIds workspaceIds: [UUID]) { + for workspaceId in workspaceIds { + setTabIcon(tabId: workspaceId, iconPath: iconPath) + } + } + func applyWorkspaceColor(_ color: String?, toWorkspaceIds workspaceIds: [UUID]) { for workspaceId in workspaceIds { setTabColor(tabId: workspaceId, color: color) @@ -7329,6 +7340,7 @@ extension TabManager { hasher.combine(workspace.customTitle ?? "") hasher.combine(workspace.customDescription ?? "") hasher.combine(workspace.customColor ?? "") + hasher.combine(workspace.customIconPath ?? "") hasher.combine(workspace.isPinned) hasher.combine(workspace.terminalScrollBarHidden) hasher.combine(workspace.panels.count) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9895ea84ae74..1c58d6854221 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4300,7 +4300,9 @@ class TerminalController { "listening_ports": workspace.listeningPorts, "remote": workspace.remoteStatusPayload(), "current_directory": v2OrNull(workspace.currentDirectory), - "custom_color": v2OrNull(workspace.customColor) + "custom_color": v2OrNull(workspace.customColor), + "custom_icon": v2OrNull(workspace.customIconPath), + "detected_icon": v2OrNull(workspace.detectedIconPath) ] if let index { payload["index"] = index @@ -5475,7 +5477,8 @@ class TerminalController { "move_up", "move_down", "move_top", "close_others", "close_above", "close_below", "mark_read", "mark_unread", - "set_color", "clear_color" + "set_color", "clear_color", + "set_icon", "clear_icon" ] var result: V2CallResult = .err(code: "invalid_params", message: "Unknown workspace action", data: [ @@ -5644,6 +5647,19 @@ class TerminalController { tabManager.setTabColor(tabId: workspace.id, color: nil) finish(["color": NSNull()]) + case "set_icon": + guard let iconRaw = v2String(params, "icon"), + let icon = WorkspaceIconValue.normalizedStorageValue(iconRaw) else { + result = .err(code: "invalid_params", message: "Missing or invalid icon", data: nil) + return + } + tabManager.setTabIcon(tabId: workspace.id, iconPath: icon) + finish(["custom_icon": v2OrNull(workspace.customIconPath)]) + + case "clear_icon": + tabManager.setTabIcon(tabId: workspace.id, iconPath: nil) + finish(["custom_icon": NSNull()]) + default: result = .err(code: "invalid_params", message: "Unknown workspace action", data: [ "action": action, @@ -17441,6 +17457,8 @@ class TerminalController { var lines: [String] = [] lines.append("tab=\(tab.id.uuidString)") lines.append("color=\(tab.customColor ?? "none")") + lines.append("icon=\(tab.customIconPath ?? "none")") + lines.append("detected_icon=\(tab.detectedIconPath ?? "none")") lines.append("cwd=\(tab.currentDirectory)") if let focused = tab.focusedPanelId, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 27371b3a53f6..681a25cf42cd 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -224,6 +224,7 @@ extension Workspace { customTitle: customTitle, customDescription: customDescription, customColor: customColor, + customIconPath: customIconPath, isPinned: isPinned, isManuallyUnread: isWorkspaceManuallyUnread, hasUnreadIndicator: hasWorkspaceUnreadIndicator, @@ -285,6 +286,7 @@ extension Workspace { setCustomTitle(snapshot.customTitle) setCustomDescription(snapshot.customDescription) setCustomColor(snapshot.customColor) + setCustomIcon(snapshot.customIconPath) isPinned = snapshot.isPinned setTerminalScrollBarHidden(snapshot.terminalScrollBarHidden ?? false) @@ -7170,10 +7172,21 @@ final class Workspace: Identifiable, ObservableObject { @Published var customDescription: String? @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" + @Published private(set) var customIconPath: String? + @Published private(set) var detectedIconPath: String? @Published private(set) var terminalScrollBarHidden: Bool = false - @Published var currentDirectory: String + @Published var currentDirectory: String { + didSet { + guard currentDirectory != oldValue else { return } + refreshDetectedWorkspaceIcon( + autoDetectEnabled: SidebarWorkspaceIconSettings.autoDetectsWorkspaceIcon() + ) + } + } @Published private(set) var surfaceTabBarDirectory: String? private(set) var preferredBrowserProfileID: UUID? + private var workspaceIconDetectionTask: Task<Void, Never>? + private var workspaceIconDetectionDirectory: String? /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) var portOrdinal: Int = 0 @@ -7375,6 +7388,8 @@ final class Workspace: Identifiable, ObservableObject { sidebarObservationSignal($customDescription), sidebarObservationSignal($isPinned), sidebarObservationSignal($customColor), + sidebarObservationSignal($customIconPath), + sidebarObservationSignal($detectedIconPath), sidebarObservationSignal($terminalScrollBarHidden), sidebarObservationSignal($latestConversationMessage), ] @@ -7773,6 +7788,8 @@ final class Workspace: Identifiable, ObservableObject { self.title = title self.customTitle = nil self.customDescription = nil + self.customIconPath = nil + self.detectedIconPath = nil let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty @@ -7905,6 +7922,7 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.selectTab(initialTabId) } tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() + refreshDetectedWorkspaceIcon(autoDetectEnabled: SidebarWorkspaceIconSettings.autoDetectsWorkspaceIcon()) } deinit { @@ -7917,6 +7935,7 @@ final class Workspace: Identifiable, ObservableObject { } activeRemoteSessionControllerID = nil remoteSessionController?.stop() + workspaceIconDetectionTask?.cancel() } func refreshSplitButtonTooltips() { @@ -8661,6 +8680,56 @@ final class Workspace: Identifiable, ObservableObject { } } + func setCustomIcon(_ iconPath: String?) { + customIconPath = WorkspaceIconValue.normalizedStorageValue(iconPath) + } + + func effectiveIconPath(autoDetectEnabled: Bool) -> String? { + customIconPath ?? (autoDetectEnabled ? detectedIconPath : nil) + } + + func refreshDetectedWorkspaceIcon(autoDetectEnabled: Bool) { + guard autoDetectEnabled else { + workspaceIconDetectionTask?.cancel() + workspaceIconDetectionTask = nil + workspaceIconDetectionDirectory = nil + if detectedIconPath != nil { + detectedIconPath = nil + } + return + } + + let directory = WorkspaceIconValue.normalizedStorageValue(currentDirectory) + guard let directory else { + workspaceIconDetectionTask?.cancel() + workspaceIconDetectionTask = nil + workspaceIconDetectionDirectory = nil + if detectedIconPath != nil { + detectedIconPath = nil + } + return + } + + guard workspaceIconDetectionDirectory != directory else { return } + workspaceIconDetectionDirectory = directory + workspaceIconDetectionTask?.cancel() + workspaceIconDetectionTask = Task { [weak self, directory] in + let detectedPath = await Task.detached(priority: .utility) { + WorkspaceIconDetector.detectedIconPath(in: directory) + }.value + + guard !Task.isCancelled, + let self, + self.workspaceIconDetectionDirectory == directory, + WorkspaceIconValue.normalizedStorageValue(self.currentDirectory) == directory else { + return + } + if self.detectedIconPath != detectedPath { + self.detectedIconPath = detectedPath + } + } + } + func setTerminalScrollBarHidden(_ hidden: Bool) { guard terminalScrollBarHidden != hidden else { return } terminalScrollBarHidden = hidden diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 6844f3ac82e3..2f719f42ade4 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5079,6 +5079,8 @@ struct SettingsView: View { private var sidebarShowWorkspaceDescription = SidebarWorkspaceDetailSettings.defaultShowWorkspaceDescription @AppStorage(SidebarWorkspaceDetailSettings.showNotificationMessageKey) private var sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage + @AppStorage(SidebarWorkspaceIconSettings.autoDetectKey) + private var sidebarAutoDetectWorkspaceIcon = SidebarWorkspaceIconSettings.defaultAutoDetect @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @@ -6361,6 +6363,18 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("sidebar.autoDetectWorkspaceIcon"), + String(localized: "settings.app.autoDetectWorkspaceIcon", defaultValue: "Auto-Detect Workspace Icons"), + subtitle: String(localized: "settings.app.autoDetectWorkspaceIcon.subtitle", defaultValue: "Use favicon, app icon, or logo files from each workspace directory when no custom icon is set.") + ) { + Toggle("", isOn: $sidebarAutoDetectWorkspaceIcon) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("sidebar.showNotificationMessage"), String(localized: "settings.app.showNotificationMessage", defaultValue: "Show Notification Message in Sidebar"), @@ -7467,6 +7481,7 @@ struct SettingsView: View { sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowWorkspaceDescription = SidebarWorkspaceDetailSettings.defaultShowWorkspaceDescription sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage + sidebarAutoDetectWorkspaceIcon = SidebarWorkspaceIconSettings.defaultAutoDetect sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue sidebarSelectionColorHex = nil diff --git a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift index 660e9775538f..55500084643c 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift @@ -71,6 +71,32 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { XCTAssertFalse(decision.hasDeferredWorkspaceObservationInvalidation) } + func testContextMenuIconChangeUpdatesDisplayedFieldsAndDefersNoisyFields() { + let current = Self.snapshot( + workspaceIconPath: nil, + latestConversationMessage: "old message", + listeningPorts: [3000] + ) + let next = Self.snapshot( + workspaceIconPath: "emoji:🚀", + latestConversationMessage: "new message", + listeningPorts: [3000, 4000] + ) + + let decision = SidebarWorkspaceSnapshotRefreshPolicy.decision( + current: current, + next: next, + force: false, + contextMenuVisible: true + ) + + XCTAssertEqual(decision.workspaceSnapshotStorage?.workspaceIconPath, "emoji:🚀") + XCTAssertEqual(decision.workspaceSnapshotStorage?.latestConversationMessage, "old message") + XCTAssertEqual(decision.workspaceSnapshotStorage?.listeningPorts, [3000]) + XCTAssertEqual(decision.pendingWorkspaceSnapshot, next) + XCTAssertTrue(decision.hasDeferredWorkspaceObservationInvalidation) + } + func testClosedContextMenuStoresNextAndClearsPending() { let current = Self.snapshot(title: "old", isPinned: false) let next = Self.snapshot(title: "new", isPinned: true) @@ -93,6 +119,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { customDescription: String? = nil, isPinned: Bool = false, customColorHex: String? = nil, + workspaceIconPath: String? = nil, remoteConnectionStatusText: String = "Disconnected", latestConversationMessage: String? = nil, listeningPorts: [Int] = [] @@ -103,6 +130,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { customDescription: customDescription, isPinned: isPinned, customColorHex: customColorHex, + workspaceIconPath: workspaceIconPath, remoteWorkspaceSidebarText: nil, remoteConnectionStatusText: remoteConnectionStatusText, remoteStateHelpText: "", diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 1b43746d6a8e..001ce45e29c0 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -197,6 +197,50 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { } } +@MainActor +final class WorkspaceIconTests: XCTestCase { + func testIconStorageNormalizesEmojiAndFilePaths() { + XCTAssertEqual(WorkspaceIconValue.normalizedStorageValue(" emoji: 🚀 "), "emoji:🚀") + XCTAssertNil(WorkspaceIconValue.normalizedStorageValue("emoji: ")) + XCTAssertNil(WorkspaceIconValue.normalizedStorageValue(" ")) + + let rawPath = "~/Desktop/cmux-icon.png" + let expectedPath = NSString( + string: NSString(string: rawPath).expandingTildeInPath + ).standardizingPath + XCTAssertEqual(WorkspaceIconValue.normalizedStorageValue(rawPath), expectedPath) + } + + func testWorkspaceCustomIconAffectsAutosaveFingerprint() { + let manager = TabManager() + guard let workspace = manager.tabs.first else { + XCTFail("Expected TabManager to initialise with a workspace") + return + } + + let cleanFingerprint = manager.sessionAutosaveFingerprint() + manager.setTabIcon(tabId: workspace.id, iconPath: "emoji:🚀") + + XCTAssertEqual(workspace.customIconPath, "emoji:🚀") + XCTAssertNotEqual(cleanFingerprint, manager.sessionAutosaveFingerprint()) + } + + func testWorkspaceIconDetectorFindsRootFavicon() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workspace-icon-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let iconURL = directory.appendingPathComponent("favicon.png") + try Data([0x89, 0x50, 0x4E, 0x47]).write(to: iconURL) + + XCTAssertEqual( + WorkspaceIconDetector.detectedIconPath(in: directory.path), + iconURL.path + ) + } +} + final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { func testRenameTabShortcutDefaultsAndMetadata() { From 4732e4e8ccaa6d064718823493c14225a2e4f880 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 18 May 2026 21:40:30 -0700 Subject: [PATCH 2/6] fix(sidebar): refresh workspace icon images --- Sources/ContentView.swift | 16 ++++--- .../Sidebar/SidebarAppearanceSupport.swift | 29 +++++++++++- ...idebarWorkspaceSnapshotRefreshPolicy.swift | 5 ++- Sources/Workspace.swift | 40 ++++++++++++++--- ...rWorkspaceSnapshotRefreshPolicyTests.swift | 2 + cmuxTests/WorkspaceUnitTests.swift | 45 +++++++++++++++++++ 6 files changed, 123 insertions(+), 14 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index fc9ecbf7a12d..af135f420868 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12279,6 +12279,7 @@ struct SidebarWorkspaceSnapshotBuilder { let isPinned: Bool let customColorHex: String? let workspaceIconPath: String? + let workspaceIconReloadToken: String? let remoteWorkspaceSidebarText: String? let remoteConnectionStatusText: String let remoteStateHelpText: String @@ -12631,7 +12632,11 @@ private struct TabItemView: View, Equatable { HStack(alignment: .top, spacing: workspaceIconPath == nil ? 0 : 10) { if let workspaceIconPath { - WorkspaceIconView(iconPath: workspaceIconPath, size: 36) + WorkspaceIconView( + iconPath: workspaceIconPath, + reloadToken: workspaceSnapshot.workspaceIconReloadToken, + size: 36 + ) .padding(.top, 1) .accessibilityIdentifier("SidebarWorkspaceIcon") } @@ -12935,7 +12940,7 @@ private struct TabItemView: View, Equatable { } } .onAppear { - refreshWorkspaceIconDetection() + refreshWorkspaceIconDetection(force: true) refreshWorkspaceSnapshot(force: true) } .task(id: finderDirectoryCacheKey) { @@ -12988,7 +12993,7 @@ private struct TabItemView: View, Equatable { refreshWorkspaceSnapshot() } .onChange(of: settings) { _ in - refreshWorkspaceIconDetection() + refreshWorkspaceIconDetection(force: true) refreshWorkspaceSnapshot(force: true) } .onDrag { @@ -13065,8 +13070,8 @@ private struct TabItemView: View, Equatable { } } - private func refreshWorkspaceIconDetection() { - tab.refreshDetectedWorkspaceIcon(autoDetectEnabled: settings.autoDetectWorkspaceIcon) + private func refreshWorkspaceIconDetection(force: Bool = false) { + tab.refreshDetectedWorkspaceIcon(autoDetectEnabled: settings.autoDetectWorkspaceIcon, force: force) } private func flushDeferredWorkspaceObservationInvalidation() { @@ -13664,6 +13669,7 @@ private struct TabItemView: View, Equatable { isPinned: tab.isPinned, customColorHex: tab.customColor, workspaceIconPath: tab.effectiveIconPath(autoDetectEnabled: settings.autoDetectWorkspaceIcon), + workspaceIconReloadToken: tab.effectiveIconReloadToken(autoDetectEnabled: settings.autoDetectWorkspaceIcon), remoteWorkspaceSidebarText: remoteWorkspaceSidebarText, remoteConnectionStatusText: remoteConnectionStatusText, remoteStateHelpText: remoteStateHelpText, diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 95c9a88e1b26..bf17567e4c66 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -163,6 +163,28 @@ enum WorkspaceIconDetector { } } +struct WorkspaceIconFileSignature: Equatable, Sendable { + let modificationTime: TimeInterval + let size: UInt64 + + static func current(for path: String, fileManager: FileManager = .default) -> Self? { + guard let attributes = try? fileManager.attributesOfItem(atPath: path) else { return nil } + let modificationTime = (attributes[.modificationDate] as? Date)?.timeIntervalSinceReferenceDate ?? 0 + let size = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 + return Self(modificationTime: modificationTime, size: size) + } +} + +struct WorkspaceIconDetectionResult: Equatable, Sendable { + let path: String? + let signature: WorkspaceIconFileSignature? + + static func detect(in directory: String) -> Self { + let path = WorkspaceIconDetector.detectedIconPath(in: directory) + return Self(path: path, signature: path.flatMap { WorkspaceIconFileSignature.current(for: $0) }) + } +} + private final class WorkspaceIconImageBox: @unchecked Sendable { let image: NSImage @@ -173,6 +195,7 @@ private final class WorkspaceIconImageBox: @unchecked Sendable { struct WorkspaceIconView: View { let iconPath: String + let reloadToken: String? let size: CGFloat @State private var loadedImage: NSImage? @@ -209,11 +232,15 @@ struct WorkspaceIconView: View { .frame(width: size, height: size) .clipShape(Circle()) .overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 0.5)) - .task(id: iconPath) { + .task(id: iconLoadKey) { await loadFileIconIfNeeded() } } + private var iconLoadKey: String { + "\(iconPath)\u{0}\(reloadToken ?? "")" + } + @ViewBuilder private var placeholderIcon: some View { Image(systemName: imageLoadFailed ? "exclamationmark.triangle" : "photo") diff --git a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift index ef90226d2838..44db01b65092 100644 --- a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift +++ b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift @@ -5,6 +5,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { let isPinned: Bool let customColorHex: String? let workspaceIconPath: String? + let workspaceIconReloadToken: String? } var contextMenuImmediateFields: ContextMenuImmediateFields { @@ -13,7 +14,8 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { customDescription: customDescription, isPinned: isPinned, customColorHex: customColorHex, - workspaceIconPath: workspaceIconPath + workspaceIconPath: workspaceIconPath, + workspaceIconReloadToken: workspaceIconReloadToken ) } @@ -26,6 +28,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { isPinned: snapshot.isPinned, customColorHex: snapshot.customColorHex, workspaceIconPath: snapshot.workspaceIconPath, + workspaceIconReloadToken: snapshot.workspaceIconReloadToken, remoteWorkspaceSidebarText: remoteWorkspaceSidebarText, remoteConnectionStatusText: remoteConnectionStatusText, remoteStateHelpText: remoteStateHelpText, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 681a25cf42cd..27b8fcc7e2d9 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7174,6 +7174,8 @@ final class Workspace: Identifiable, ObservableObject { @Published var customColor: String? // hex string, e.g. "#C0392B" @Published private(set) var customIconPath: String? @Published private(set) var detectedIconPath: String? + @Published private(set) var customIconRevision = 0 + @Published private(set) var detectedIconRevision = 0 @Published private(set) var terminalScrollBarHidden: Bool = false @Published var currentDirectory: String { didSet { @@ -7187,6 +7189,7 @@ final class Workspace: Identifiable, ObservableObject { private(set) var preferredBrowserProfileID: UUID? private var workspaceIconDetectionTask: Task<Void, Never>? private var workspaceIconDetectionDirectory: String? + private var detectedIconSignature: WorkspaceIconFileSignature? /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) var portOrdinal: Int = 0 @@ -7390,6 +7393,8 @@ final class Workspace: Identifiable, ObservableObject { sidebarObservationSignal($customColor), sidebarObservationSignal($customIconPath), sidebarObservationSignal($detectedIconPath), + sidebarObservationSignal($customIconRevision), + sidebarObservationSignal($detectedIconRevision), sidebarObservationSignal($terminalScrollBarHidden), sidebarObservationSignal($latestConversationMessage), ] @@ -7790,6 +7795,8 @@ final class Workspace: Identifiable, ObservableObject { self.customDescription = nil self.customIconPath = nil self.detectedIconPath = nil + self.customIconRevision = 0 + self.detectedIconRevision = 0 let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty @@ -8681,20 +8688,34 @@ final class Workspace: Identifiable, ObservableObject { } func setCustomIcon(_ iconPath: String?) { - customIconPath = WorkspaceIconValue.normalizedStorageValue(iconPath) + let normalized = WorkspaceIconValue.normalizedStorageValue(iconPath) + if customIconPath != normalized || normalized != nil { + customIconRevision &+= 1 + } + customIconPath = normalized } func effectiveIconPath(autoDetectEnabled: Bool) -> String? { customIconPath ?? (autoDetectEnabled ? detectedIconPath : nil) } - func refreshDetectedWorkspaceIcon(autoDetectEnabled: Bool) { + func effectiveIconReloadToken(autoDetectEnabled: Bool) -> String? { + if let customIconPath { + return "\(customIconPath)\u{0}custom:\(customIconRevision)" + } + guard autoDetectEnabled, let detectedIconPath else { return nil } + return "\(detectedIconPath)\u{0}detected:\(detectedIconRevision)" + } + + func refreshDetectedWorkspaceIcon(autoDetectEnabled: Bool, force: Bool = false) { guard autoDetectEnabled else { workspaceIconDetectionTask?.cancel() workspaceIconDetectionTask = nil workspaceIconDetectionDirectory = nil + detectedIconSignature = nil if detectedIconPath != nil { detectedIconPath = nil + detectedIconRevision &+= 1 } return } @@ -8704,18 +8725,20 @@ final class Workspace: Identifiable, ObservableObject { workspaceIconDetectionTask?.cancel() workspaceIconDetectionTask = nil workspaceIconDetectionDirectory = nil + detectedIconSignature = nil if detectedIconPath != nil { detectedIconPath = nil + detectedIconRevision &+= 1 } return } - guard workspaceIconDetectionDirectory != directory else { return } + guard force || workspaceIconDetectionDirectory != directory else { return } workspaceIconDetectionDirectory = directory workspaceIconDetectionTask?.cancel() workspaceIconDetectionTask = Task { [weak self, directory] in - let detectedPath = await Task.detached(priority: .utility) { - WorkspaceIconDetector.detectedIconPath(in: directory) + let result = await Task.detached(priority: .utility) { + WorkspaceIconDetectionResult.detect(in: directory) }.value guard !Task.isCancelled, @@ -8724,8 +8747,11 @@ final class Workspace: Identifiable, ObservableObject { WorkspaceIconValue.normalizedStorageValue(self.currentDirectory) == directory else { return } - if self.detectedIconPath != detectedPath { - self.detectedIconPath = detectedPath + let didChange = self.detectedIconPath != result.path || self.detectedIconSignature != result.signature + if didChange { + self.detectedIconPath = result.path + self.detectedIconSignature = result.signature + self.detectedIconRevision &+= 1 } } } diff --git a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift index 55500084643c..06edc320c971 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift @@ -120,6 +120,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { isPinned: Bool = false, customColorHex: String? = nil, workspaceIconPath: String? = nil, + workspaceIconReloadToken: String? = nil, remoteConnectionStatusText: String = "Disconnected", latestConversationMessage: String? = nil, listeningPorts: [Int] = [] @@ -131,6 +132,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { isPinned: isPinned, customColorHex: customColorHex, workspaceIconPath: workspaceIconPath, + workspaceIconReloadToken: workspaceIconReloadToken, remoteWorkspaceSidebarText: nil, remoteConnectionStatusText: remoteConnectionStatusText, remoteStateHelpText: "", diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 001ce45e29c0..faab075c32c4 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -225,6 +225,21 @@ final class WorkspaceIconTests: XCTestCase { XCTAssertNotEqual(cleanFingerprint, manager.sessionAutosaveFingerprint()) } + func testSettingSameCustomIconRefreshesReloadToken() { + let manager = TabManager() + guard let workspace = manager.tabs.first else { + XCTFail("Expected TabManager to initialise with a workspace") + return + } + + manager.setTabIcon(tabId: workspace.id, iconPath: "~/Desktop/cmux-icon.png") + let firstToken = workspace.effectiveIconReloadToken(autoDetectEnabled: false) + manager.setTabIcon(tabId: workspace.id, iconPath: "~/Desktop/cmux-icon.png") + + XCTAssertNotNil(firstToken) + XCTAssertNotEqual(firstToken, workspace.effectiveIconReloadToken(autoDetectEnabled: false)) + } + func testWorkspaceIconDetectorFindsRootFavicon() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-workspace-icon-\(UUID().uuidString)", isDirectory: true) @@ -239,6 +254,36 @@ final class WorkspaceIconTests: XCTestCase { iconURL.path ) } + + func testWorkspaceIconDetectionForceRescansSameDirectory() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workspace-icon-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let manager = TabManager() + guard let workspace = manager.tabs.first else { + XCTFail("Expected TabManager to initialise with a workspace") + return + } + workspace.currentDirectory = directory.path + workspace.refreshDetectedWorkspaceIcon(autoDetectEnabled: true, force: true) + + let iconURL = directory.appendingPathComponent("favicon.png") + try Data([0x89, 0x50, 0x4E, 0x47]).write(to: iconURL) + workspace.refreshDetectedWorkspaceIcon(autoDetectEnabled: true) + + let expectation = expectation(description: "detected icon refreshes") + let cancellable = workspace.$detectedIconPath.sink { detectedPath in + if detectedPath == iconURL.path { + expectation.fulfill() + } + } + + workspace.refreshDetectedWorkspaceIcon(autoDetectEnabled: true, force: true) + await fulfillment(of: [expectation], timeout: 3) + withExtendedLifetime(cancellable) {} + } } From f35b77c9c71a82894c109762ced96439398986dd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 18 May 2026 21:53:29 -0700 Subject: [PATCH 3/6] fix(sidebar): harden workspace icon handling --- CLI/cmux.swift | 9 +++-- Resources/Localizable.xcstrings | 29 +++++++------- Sources/ContentView.swift | 8 +++- .../Sidebar/SidebarAppearanceSupport.swift | 38 +++++++++++-------- Sources/TerminalController.swift | 9 ++++- Sources/Workspace.swift | 32 ++++++++-------- 6 files changed, 75 insertions(+), 50 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d3f3c7a2037e..2bce7dd5d6a6 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4658,7 +4658,10 @@ struct CMUXCLI { iconOpt ?? (action == "set_icon" ? (inferredPositional.isEmpty ? nil : inferredPositional) : nil) )?.trimmingCharacters(in: .whitespacesAndNewlines) if action == "set_icon", (icon?.isEmpty ?? true) { - throw CLIError(message: "workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)") + throw CLIError(message: String( + localized: "cli.error.workspaceActionSetIconRequiresIcon", + defaultValue: "workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)" + )) } let description = ( @@ -9510,7 +9513,7 @@ struct CMUXCLI { cmux reorder-workspace --workspace workspace:3 --after workspace:1 """ case "workspace-action": - return """ + return String(localized: "cli.help.workspaceAction", defaultValue: """ Usage: cmux workspace-action --action <name> [flags] Perform workspace context-menu actions from CLI/socket. @@ -9550,7 +9553,7 @@ struct CMUXCLI { cmux workspace-action --action set-description $'Ship checklist\n- verify build\n- post notes' cmux workspace-action clear-color cmux workspace-action clear-icon - """ + """) case "tab-action": return """ Usage: cmux tab-action --action <name> [flags] diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a2cf1c1ba931..b159b2c029bc 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -112817,18 +112817,21 @@ "rightSidebar.remote.error.appDelegateUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: App delegate not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: アプリデリゲートを利用できません" } } } }, "notificationHook.failure.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Notification Hook Failed" } }, "ja": { "stringUnit": { "state": "translated", "value": "通知フックに失敗しました" } } } }, "notificationHook.failure.body": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "cmux used default notification behavior because '%@' failed." } }, "ja": { "stringUnit": { "state": "translated", "value": "'%@' が失敗したため、cmux は既定の通知動作を使用しました。" } } } }, - "alert.emojiIcon.apply": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Apply" } }, "ja": { "stringUnit": { "state": "translated", "value": "適用" } } } }, - "alert.emojiIcon.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Cancel" } }, "ja": { "stringUnit": { "state": "translated", "value": "キャンセル" } } } }, - "alert.emojiIcon.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enter an emoji to use as the workspace icon." } }, "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースアイコンとして使う絵文字を入力してください。" } } } }, - "alert.emojiIcon.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "🚀" } }, "ja": { "stringUnit": { "state": "translated", "value": "🚀" } } } }, - "alert.emojiIcon.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Emoji Workspace Icon" } }, "ja": { "stringUnit": { "state": "translated", "value": "絵文字ワークスペースアイコン" } } } }, - "contextMenu.clearIcon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Clear Icon" } }, "ja": { "stringUnit": { "state": "translated", "value": "アイコンをクリア" } } } }, - "contextMenu.setEmojiIcon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Set Emoji Icon…" } }, "ja": { "stringUnit": { "state": "translated", "value": "絵文字アイコンを設定…" } } } }, - "contextMenu.setIconFromFile": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Set Icon from File…" } }, "ja": { "stringUnit": { "state": "translated", "value": "ファイルからアイコンを設定…" } } } }, - "contextMenu.workspaceIcon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Workspace Icon" } }, "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースアイコン" } } } }, - "openPanel.workspaceIcon.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose Workspace Icon" } }, "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースアイコンを選択" } } } }, - "settings.app.autoDetectWorkspaceIcon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Auto-Detect Workspace Icons" } }, "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースアイコンを自動検出" } } } }, - "settings.app.autoDetectWorkspaceIcon.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Use favicon, app icon, or logo files from each workspace directory when no custom icon is set." } }, "ja": { "stringUnit": { "state": "translated", "value": "カスタムアイコンがない場合に、各ワークスペースディレクトリの favicon、アプリアイコン、ロゴファイルを使います。" } } } }, - "settings.search.alias.setting.app.auto-detect-workspace-icon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar" } }, "ja": { "stringUnit": { "state": "translated", "value": "sidebar.autoDetectWorkspaceIcon favicon アイコン ロゴ アプリアイコン ワークスペース アバター" } } } } + "alert.emojiIcon.apply": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Apply"}},"bs":{"stringUnit":{"state":"translated","value":"Apply"}},"da":{"stringUnit":{"state":"translated","value":"Apply"}},"de":{"stringUnit":{"state":"translated","value":"Apply"}},"en":{"stringUnit":{"state":"translated","value":"Apply"}},"es":{"stringUnit":{"state":"translated","value":"Apply"}},"fr":{"stringUnit":{"state":"translated","value":"Apply"}},"it":{"stringUnit":{"state":"translated","value":"Apply"}},"ja":{"stringUnit":{"state":"translated","value":"適用"}},"ko":{"stringUnit":{"state":"translated","value":"Apply"}},"nb":{"stringUnit":{"state":"translated","value":"Apply"}},"pl":{"stringUnit":{"state":"translated","value":"Apply"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Apply"}},"ru":{"stringUnit":{"state":"translated","value":"Apply"}},"th":{"stringUnit":{"state":"translated","value":"Apply"}},"tr":{"stringUnit":{"state":"translated","value":"Apply"}},"uk":{"stringUnit":{"state":"translated","value":"Apply"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Apply"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Apply"}}}}, + "alert.emojiIcon.cancel": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Cancel"}},"bs":{"stringUnit":{"state":"translated","value":"Cancel"}},"da":{"stringUnit":{"state":"translated","value":"Cancel"}},"de":{"stringUnit":{"state":"translated","value":"Cancel"}},"en":{"stringUnit":{"state":"translated","value":"Cancel"}},"es":{"stringUnit":{"state":"translated","value":"Cancel"}},"fr":{"stringUnit":{"state":"translated","value":"Cancel"}},"it":{"stringUnit":{"state":"translated","value":"Cancel"}},"ja":{"stringUnit":{"state":"translated","value":"キャンセル"}},"ko":{"stringUnit":{"state":"translated","value":"Cancel"}},"nb":{"stringUnit":{"state":"translated","value":"Cancel"}},"pl":{"stringUnit":{"state":"translated","value":"Cancel"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Cancel"}},"ru":{"stringUnit":{"state":"translated","value":"Cancel"}},"th":{"stringUnit":{"state":"translated","value":"Cancel"}},"tr":{"stringUnit":{"state":"translated","value":"Cancel"}},"uk":{"stringUnit":{"state":"translated","value":"Cancel"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Cancel"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Cancel"}}}}, + "alert.emojiIcon.message": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"bs":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"da":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"de":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"en":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"es":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"fr":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"it":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコンとして使う絵文字を入力してください。"}},"ko":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"nb":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"pl":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"ru":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"th":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"tr":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"uk":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}}}}, + "alert.emojiIcon.placeholder": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"🚀"}},"bs":{"stringUnit":{"state":"translated","value":"🚀"}},"da":{"stringUnit":{"state":"translated","value":"🚀"}},"de":{"stringUnit":{"state":"translated","value":"🚀"}},"en":{"stringUnit":{"state":"translated","value":"🚀"}},"es":{"stringUnit":{"state":"translated","value":"🚀"}},"fr":{"stringUnit":{"state":"translated","value":"🚀"}},"it":{"stringUnit":{"state":"translated","value":"🚀"}},"ja":{"stringUnit":{"state":"translated","value":"🚀"}},"ko":{"stringUnit":{"state":"translated","value":"🚀"}},"nb":{"stringUnit":{"state":"translated","value":"🚀"}},"pl":{"stringUnit":{"state":"translated","value":"🚀"}},"pt-BR":{"stringUnit":{"state":"translated","value":"🚀"}},"ru":{"stringUnit":{"state":"translated","value":"🚀"}},"th":{"stringUnit":{"state":"translated","value":"🚀"}},"tr":{"stringUnit":{"state":"translated","value":"🚀"}},"uk":{"stringUnit":{"state":"translated","value":"🚀"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"🚀"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"🚀"}}}}, + "alert.emojiIcon.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"bs":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"da":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"de":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"en":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"es":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"fr":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"it":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"ja":{"stringUnit":{"state":"translated","value":"絵文字ワークスペースアイコン"}},"ko":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"nb":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"pl":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"ru":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"th":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"tr":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"uk":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}}}}, + "contextMenu.clearIcon": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"bs":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"da":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"de":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"en":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"es":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"fr":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"it":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"ja":{"stringUnit":{"state":"translated","value":"アイコンをクリア"}},"ko":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"nb":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"pl":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"ru":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"th":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"tr":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"uk":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Clear Icon"}}}}, + "contextMenu.setEmojiIcon": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"bs":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"da":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"de":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"en":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"es":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"fr":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"it":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"ja":{"stringUnit":{"state":"translated","value":"絵文字アイコンを設定…"}},"ko":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"nb":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"pl":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"ru":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"th":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"tr":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"uk":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Set Emoji Icon…"}}}}, + "contextMenu.setIconFromFile": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"bs":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"da":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"de":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"en":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"es":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"fr":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"it":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"ja":{"stringUnit":{"state":"translated","value":"ファイルからアイコンを設定…"}},"ko":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"nb":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"pl":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"ru":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"th":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"tr":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"uk":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Set Icon from File…"}}}}, + "contextMenu.workspaceIcon": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"bs":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"da":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"de":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"en":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"es":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"fr":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"it":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコン"}},"ko":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"nb":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"pl":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"ru":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"th":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"tr":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"uk":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Workspace Icon"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Workspace Icon"}}}}, + "openPanel.workspaceIcon.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"bs":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"da":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"de":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"en":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"es":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"fr":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"it":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコンを選択"}},"ko":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"nb":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"pl":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"ru":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"th":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"tr":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"uk":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Choose Workspace Icon"}}}}, + "settings.app.autoDetectWorkspaceIcon": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"bs":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"da":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"de":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"en":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"es":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"fr":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"it":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコンを自動検出"}},"ko":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"nb":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"pl":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"ru":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"th":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"tr":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"uk":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Auto-Detect Workspace Icons"}}}}, + "settings.app.autoDetectWorkspaceIcon.subtitle": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"bs":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"da":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"de":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"en":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"es":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"fr":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"it":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"ja":{"stringUnit":{"state":"translated","value":"カスタムアイコンがない場合に、各ワークスペースディレクトリの favicon、アプリアイコン、ロゴファイルを使います。"}},"ko":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"nb":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"pl":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"ru":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"th":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"tr":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"uk":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Use favicon, app icon, or logo files from each workspace directory when no custom icon is set."}}}}, + "settings.search.alias.setting.app.auto-detect-workspace-icon": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"bs":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"da":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"de":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"en":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"es":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"fr":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"it":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"ja":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon アイコン ロゴ アプリアイコン ワークスペース アバター"}},"ko":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"nb":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"pl":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"pt-BR":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"ru":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"th":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"tr":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"uk":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"sidebar.autoDetectWorkspaceIcon favicon icon logo app icon workspace avatar"}}}}, + "cli.error.workspaceActionSetIconRequiresIcon": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"bs":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"da":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"de":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"en":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"es":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"fr":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"it":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"ja":{"stringUnit":{"state":"translated","value":"workspace-action set-icon には --icon <path|emoji:🚀>(または末尾のアイコン)が必要です"}},"ko":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"nb":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"pl":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"pt-BR":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"ru":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"th":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"tr":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"uk":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)"}}}}, + "cli.help.workspaceAction": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"bs":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"da":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"de":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"en":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"es":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"fr":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"it":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"ja":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"ko":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"nb":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"pl":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"ru":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"th":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"tr":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"uk":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Usage: cmux workspace-action --action <name> [flags]\n\nPerform workspace context-menu actions from CLI/socket.\n\nActions:\n pin | unpin\n rename | clear-name\n set-description | clear-description\n move-up | move-down | move-top\n close-others | close-above | close-below\n mark-read | mark-unread\n set-color | clear-color\n set-icon | clear-icon\n\nFlags:\n --action <name> Action name (required if not positional)\n --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)\n --title <text> Title for rename\n --color <name|#hex> Color for set-color (name or #RRGGBB hex)\n --icon <path|emoji:🚀> Icon for set-icon\n --description <text> Description for set-description\n\nNamed colors:\n Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,\n Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal\n\nExample:\n cmux workspace-action --workspace workspace:2 --action pin\n cmux workspace-action --action rename --title \"infra\"\n cmux workspace-action close-others\n cmux workspace-action --action set-color --color blue\n cmux workspace-action --action set-color --color \"#C0392B\"\n cmux workspace-action set-color Amber\n cmux workspace-action --action set-icon --icon emoji:🚀\n cmux workspace-action set-icon ~/Desktop/icon.png\n cmux workspace-action --action set-description --description \"Ship checklist\"\n cmux workspace-action --action set-description $'Ship checklist\\n- verify build\\n- post notes'\n cmux workspace-action clear-color\n cmux workspace-action clear-icon"}}}}, + "socket.workspace.icon.invalid": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"bs":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"da":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"de":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"en":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"es":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"fr":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"it":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコンが無効です。\"icon\" に絵文字または画像パスを指定するか、\"clear_icon\" を使って削除してください。"}},"ko":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"nb":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"pl":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"ru":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"th":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"tr":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"uk":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it."}}}} } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index af135f420868..1c0689e98482 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13227,7 +13227,7 @@ private struct TabItemView: View, Equatable { } Menu(String(localized: "contextMenu.workspaceIcon", defaultValue: "Workspace Icon")) { - if tab.customIconPath != nil { + if hasCustomIcon(in: targetIds) { Button { applyTabIcon(nil, targetIds: targetIds) } label: { @@ -13545,6 +13545,12 @@ private struct TabItemView: View, Equatable { targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } } + private func hasCustomIcon(in targetIds: [UUID]) -> Bool { + targetIds.contains { targetId in + tabManager.tabs.first(where: { $0.id == targetId })?.customIconPath != nil + } + } + private func syncSelectionAfterMutation() { let existingIds = Set(tabManager.tabs.map { $0.id }) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index bf17567e4c66..b81da13e7ad9 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -63,9 +63,9 @@ enum WorkspaceIconValue: Equatable, Sendable { enum WorkspaceIconDetector { static let standardIconFilenames = [ - "favicon.png", "favicon.ico", "favicon.svg", - "icon.png", "icon.svg", - "logo.png", "logo.svg", + "favicon.png", "favicon.jpg", "favicon.jpeg", + "icon.png", "icon.jpg", "icon.jpeg", + "logo.png", "logo.jpg", "logo.jpeg", ] static let standardIconSubdirectories = [ @@ -120,6 +120,7 @@ enum WorkspaceIconDetector { ? directory : (directory as NSString).appendingPathComponent(subdirectory) for filename in standardIconFilenames { + guard !Task.isCancelled else { return nil } let path = (basePath as NSString).appendingPathComponent(filename) if fileManager.fileExists(atPath: path) { return path @@ -138,9 +139,13 @@ enum WorkspaceIconDetector { guard let contents = try? fileManager.contentsOfDirectory(atPath: iconsetPath) else { continue } + guard !Task.isCancelled else { return nil } let pngs = contents .filter { $0.lowercased().hasSuffix(".png") } - .sorted { lhs, rhs in lhs.localizedStandardCompare(rhs) == .orderedDescending } + .sorted { lhs, rhs in + fileSize(at: (iconsetPath as NSString).appendingPathComponent(lhs), fileManager: fileManager) + > fileSize(at: (iconsetPath as NSString).appendingPathComponent(rhs), fileManager: fileManager) + } if let largest = pngs.first { return (iconsetPath as NSString).appendingPathComponent(largest) } @@ -154,6 +159,7 @@ enum WorkspaceIconDetector { fileManager: FileManager ) -> String? { for relativePath in relativePaths { + guard !Task.isCancelled else { return nil } let path = (directory as NSString).appendingPathComponent(relativePath) if fileManager.fileExists(atPath: path) { return path @@ -161,6 +167,14 @@ enum WorkspaceIconDetector { } return nil } + + private static func fileSize(at path: String, fileManager: FileManager) -> UInt64 { + guard let attributes = try? fileManager.attributesOfItem(atPath: path), + let size = attributes[.size] as? NSNumber else { + return 0 + } + return size.uint64Value + } } struct WorkspaceIconFileSignature: Equatable, Sendable { @@ -185,14 +199,6 @@ struct WorkspaceIconDetectionResult: Equatable, Sendable { } } -private final class WorkspaceIconImageBox: @unchecked Sendable { - let image: NSImage - - init(image: NSImage) { - self.image = image - } -} - struct WorkspaceIconView: View { let iconPath: String let reloadToken: String? @@ -263,13 +269,13 @@ struct WorkspaceIconView: View { loadedImagePath = path imageLoadFailed = false - let imageBox = await Task.detached(priority: .utility) { - NSImage(contentsOfFile: path).map(WorkspaceIconImageBox.init(image:)) + let imageData = await Task.detached(priority: .utility) { + try? Data(contentsOf: URL(fileURLWithPath: path), options: [.mappedIfSafe]) }.value guard !Task.isCancelled, loadedImagePath == path else { return } - loadedImage = imageBox?.image - imageLoadFailed = imageBox == nil + loadedImage = imageData.flatMap(NSImage.init(data:)) + imageLoadFailed = loadedImage == nil } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 1c58d6854221..1fb8e70636cb 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5650,7 +5650,14 @@ class TerminalController { case "set_icon": guard let iconRaw = v2String(params, "icon"), let icon = WorkspaceIconValue.normalizedStorageValue(iconRaw) else { - result = .err(code: "invalid_params", message: "Missing or invalid icon", data: nil) + result = .err( + code: "invalid_params", + message: String( + localized: "socket.workspace.icon.invalid", + defaultValue: "Invalid workspace icon. Provide an emoji or image path in \"icon\", or use \"clear_icon\" to remove it." + ), + data: nil + ) return } tabManager.setTabIcon(tabId: workspace.id, iconPath: icon) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 27b8fcc7e2d9..3dbc0dd06446 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8736,22 +8736,22 @@ final class Workspace: Identifiable, ObservableObject { guard force || workspaceIconDetectionDirectory != directory else { return } workspaceIconDetectionDirectory = directory workspaceIconDetectionTask?.cancel() - workspaceIconDetectionTask = Task { [weak self, directory] in - let result = await Task.detached(priority: .utility) { - WorkspaceIconDetectionResult.detect(in: directory) - }.value - - guard !Task.isCancelled, - let self, - self.workspaceIconDetectionDirectory == directory, - WorkspaceIconValue.normalizedStorageValue(self.currentDirectory) == directory else { - return - } - let didChange = self.detectedIconPath != result.path || self.detectedIconSignature != result.signature - if didChange { - self.detectedIconPath = result.path - self.detectedIconSignature = result.signature - self.detectedIconRevision &+= 1 + workspaceIconDetectionTask = Task.detached(priority: .utility) { [weak self, directory] in + let result = WorkspaceIconDetectionResult.detect(in: directory) + guard !Task.isCancelled else { return } + + await MainActor.run { [weak self] in + guard let self, + self.workspaceIconDetectionDirectory == directory, + WorkspaceIconValue.normalizedStorageValue(self.currentDirectory) == directory else { + return + } + let didChange = self.detectedIconPath != result.path || self.detectedIconSignature != result.signature + if didChange { + self.detectedIconPath = result.path + self.detectedIconSignature = result.signature + self.detectedIconRevision &+= 1 + } } } } From 1df9ec5188fc53adc6671bd18965d22710f05aa9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 18 May 2026 22:16:52 -0700 Subject: [PATCH 4/6] fix(sidebar): polish workspace icon review cases --- Resources/Localizable.xcstrings | 2 ++ Sources/ContentView.swift | 20 +++++++++++++++--- Sources/Workspace.swift | 13 +++++++----- cmuxTests/WorkspaceUnitTests.swift | 33 ++++++++++++++++++------------ 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b159b2c029bc..2041d25849d9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -112820,6 +112820,8 @@ "alert.emojiIcon.apply": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Apply"}},"bs":{"stringUnit":{"state":"translated","value":"Apply"}},"da":{"stringUnit":{"state":"translated","value":"Apply"}},"de":{"stringUnit":{"state":"translated","value":"Apply"}},"en":{"stringUnit":{"state":"translated","value":"Apply"}},"es":{"stringUnit":{"state":"translated","value":"Apply"}},"fr":{"stringUnit":{"state":"translated","value":"Apply"}},"it":{"stringUnit":{"state":"translated","value":"Apply"}},"ja":{"stringUnit":{"state":"translated","value":"適用"}},"ko":{"stringUnit":{"state":"translated","value":"Apply"}},"nb":{"stringUnit":{"state":"translated","value":"Apply"}},"pl":{"stringUnit":{"state":"translated","value":"Apply"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Apply"}},"ru":{"stringUnit":{"state":"translated","value":"Apply"}},"th":{"stringUnit":{"state":"translated","value":"Apply"}},"tr":{"stringUnit":{"state":"translated","value":"Apply"}},"uk":{"stringUnit":{"state":"translated","value":"Apply"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Apply"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Apply"}}}}, "alert.emojiIcon.cancel": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Cancel"}},"bs":{"stringUnit":{"state":"translated","value":"Cancel"}},"da":{"stringUnit":{"state":"translated","value":"Cancel"}},"de":{"stringUnit":{"state":"translated","value":"Cancel"}},"en":{"stringUnit":{"state":"translated","value":"Cancel"}},"es":{"stringUnit":{"state":"translated","value":"Cancel"}},"fr":{"stringUnit":{"state":"translated","value":"Cancel"}},"it":{"stringUnit":{"state":"translated","value":"Cancel"}},"ja":{"stringUnit":{"state":"translated","value":"キャンセル"}},"ko":{"stringUnit":{"state":"translated","value":"Cancel"}},"nb":{"stringUnit":{"state":"translated","value":"Cancel"}},"pl":{"stringUnit":{"state":"translated","value":"Cancel"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Cancel"}},"ru":{"stringUnit":{"state":"translated","value":"Cancel"}},"th":{"stringUnit":{"state":"translated","value":"Cancel"}},"tr":{"stringUnit":{"state":"translated","value":"Cancel"}},"uk":{"stringUnit":{"state":"translated","value":"Cancel"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Cancel"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Cancel"}}}}, "alert.emojiIcon.message": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"bs":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"da":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"de":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"en":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"es":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"fr":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"it":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコンとして使う絵文字を入力してください。"}},"ko":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"nb":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"pl":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"ru":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"th":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"tr":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"uk":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}}}}, + "alert.emojiIcon.invalid.message": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"bs":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"da":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"de":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"en":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"es":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"fr":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"it":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコンとして使う絵文字を1つ以上入力してください。"}},"ko":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"nb":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"pl":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"ru":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"th":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"tr":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"uk":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}}}}, + "alert.emojiIcon.invalid.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"bs":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"da":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"de":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"en":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"es":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"fr":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"it":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"ja":{"stringUnit":{"state":"translated","value":"絵文字が必要です"}},"ko":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"nb":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"pl":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"ru":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"th":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"tr":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"uk":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Emoji Required"}}}}, "alert.emojiIcon.placeholder": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"🚀"}},"bs":{"stringUnit":{"state":"translated","value":"🚀"}},"da":{"stringUnit":{"state":"translated","value":"🚀"}},"de":{"stringUnit":{"state":"translated","value":"🚀"}},"en":{"stringUnit":{"state":"translated","value":"🚀"}},"es":{"stringUnit":{"state":"translated","value":"🚀"}},"fr":{"stringUnit":{"state":"translated","value":"🚀"}},"it":{"stringUnit":{"state":"translated","value":"🚀"}},"ja":{"stringUnit":{"state":"translated","value":"🚀"}},"ko":{"stringUnit":{"state":"translated","value":"🚀"}},"nb":{"stringUnit":{"state":"translated","value":"🚀"}},"pl":{"stringUnit":{"state":"translated","value":"🚀"}},"pt-BR":{"stringUnit":{"state":"translated","value":"🚀"}},"ru":{"stringUnit":{"state":"translated","value":"🚀"}},"th":{"stringUnit":{"state":"translated","value":"🚀"}},"tr":{"stringUnit":{"state":"translated","value":"🚀"}},"uk":{"stringUnit":{"state":"translated","value":"🚀"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"🚀"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"🚀"}}}}, "alert.emojiIcon.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"bs":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"da":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"de":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"en":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"es":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"fr":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"it":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"ja":{"stringUnit":{"state":"translated","value":"絵文字ワークスペースアイコン"}},"ko":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"nb":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"pl":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"ru":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"th":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"tr":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"uk":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}}}}, "contextMenu.clearIcon": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"bs":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"da":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"de":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"en":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"es":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"fr":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"it":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"ja":{"stringUnit":{"state":"translated","value":"アイコンをクリア"}},"ko":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"nb":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"pl":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"ru":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"th":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"tr":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"uk":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Clear Icon"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Clear Icon"}}}}, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1c0689e98482..c83698bbb6e0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12992,8 +12992,10 @@ private struct TabItemView: View, Equatable { refreshWorkspaceIconDetection() refreshWorkspaceSnapshot() } - .onChange(of: settings) { _ in + .onChange(of: settings.autoDetectWorkspaceIcon) { refreshWorkspaceIconDetection(force: true) + } + .onChange(of: settings) { refreshWorkspaceSnapshot(force: true) } .onDrag { @@ -14052,13 +14054,25 @@ private struct TabItemView: View, Equatable { alert.window.initialFirstResponder = input let response = alert.runModal() - guard response == .alertFirstButtonReturn, - let normalized = WorkspaceIconValue.normalizedStorageValue("emoji:\(input.stringValue)") else { + guard response == .alertFirstButtonReturn else { + return + } + guard let normalized = WorkspaceIconValue.normalizedStorageValue("emoji:\(input.stringValue)") else { + showInvalidEmojiIconAlert() + promptEmojiIcon(targetIds: targetIds) return } applyTabIcon(normalized, targetIds: targetIds) } + private func showInvalidEmojiIconAlert() { + let alert = NSAlert() + alert.messageText = String(localized: "alert.emojiIcon.invalid.title", defaultValue: "Emoji Required") + alert.informativeText = String(localized: "alert.emojiIcon.invalid.message", defaultValue: "Enter at least one emoji to use as the workspace icon.") + alert.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) + alert.runModal() + } + private func promptCustomColor(targetIds: [UUID]) { let alert = NSAlert() alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 3dbc0dd06446..419562a52c3d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8707,7 +8707,8 @@ final class Workspace: Identifiable, ObservableObject { return "\(detectedIconPath)\u{0}detected:\(detectedIconRevision)" } - func refreshDetectedWorkspaceIcon(autoDetectEnabled: Bool, force: Bool = false) { + @discardableResult + func refreshDetectedWorkspaceIcon(autoDetectEnabled: Bool, force: Bool = false) -> Task<Void, Never>? { guard autoDetectEnabled else { workspaceIconDetectionTask?.cancel() workspaceIconDetectionTask = nil @@ -8717,7 +8718,7 @@ final class Workspace: Identifiable, ObservableObject { detectedIconPath = nil detectedIconRevision &+= 1 } - return + return nil } let directory = WorkspaceIconValue.normalizedStorageValue(currentDirectory) @@ -8730,13 +8731,13 @@ final class Workspace: Identifiable, ObservableObject { detectedIconPath = nil detectedIconRevision &+= 1 } - return + return nil } - guard force || workspaceIconDetectionDirectory != directory else { return } + guard force || workspaceIconDetectionDirectory != directory else { return nil } workspaceIconDetectionDirectory = directory workspaceIconDetectionTask?.cancel() - workspaceIconDetectionTask = Task.detached(priority: .utility) { [weak self, directory] in + let task = Task.detached(priority: .utility) { [weak self, directory] in let result = WorkspaceIconDetectionResult.detect(in: directory) guard !Task.isCancelled else { return } @@ -8754,6 +8755,8 @@ final class Workspace: Identifiable, ObservableObject { } } } + workspaceIconDetectionTask = task + return task } func setTerminalScrollBarHidden(_ hidden: Bool) { diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index faab075c32c4..794e221470f0 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -261,28 +261,35 @@ final class WorkspaceIconTests: XCTestCase { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } + let iconURL = directory.appendingPathComponent("favicon.png") + try Data([0x89, 0x50, 0x4E, 0x47]).write(to: iconURL) + let manager = TabManager() guard let workspace = manager.tabs.first else { XCTFail("Expected TabManager to initialise with a workspace") return } workspace.currentDirectory = directory.path - workspace.refreshDetectedWorkspaceIcon(autoDetectEnabled: true, force: true) - - let iconURL = directory.appendingPathComponent("favicon.png") - try Data([0x89, 0x50, 0x4E, 0x47]).write(to: iconURL) - workspace.refreshDetectedWorkspaceIcon(autoDetectEnabled: true) - let expectation = expectation(description: "detected icon refreshes") - let cancellable = workspace.$detectedIconPath.sink { detectedPath in - if detectedPath == iconURL.path { - expectation.fulfill() - } + let initialTask = workspace.refreshDetectedWorkspaceIcon(autoDetectEnabled: true, force: true) + if let initialTask { + await initialTask.value } + XCTAssertEqual(workspace.detectedIconPath, iconURL.path) - workspace.refreshDetectedWorkspaceIcon(autoDetectEnabled: true, force: true) - await fulfillment(of: [expectation], timeout: 3) - withExtendedLifetime(cancellable) {} + let firstToken = workspace.effectiveIconReloadToken(autoDetectEnabled: true) + XCTAssertNotNil(firstToken) + + try Data([0x89, 0x50, 0x4E, 0x47, 0x01]).write(to: iconURL) + let skippedTask = workspace.refreshDetectedWorkspaceIcon(autoDetectEnabled: true) + XCTAssertNil(skippedTask) + XCTAssertEqual(firstToken, workspace.effectiveIconReloadToken(autoDetectEnabled: true)) + + let rescanTask = workspace.refreshDetectedWorkspaceIcon(autoDetectEnabled: true, force: true) + if let rescanTask { + await rescanTask.value + } + XCTAssertNotEqual(firstToken, workspace.effectiveIconReloadToken(autoDetectEnabled: true)) } } From 839cf5bdf53897233746a85fcde501ba5c1d667c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 18 May 2026 22:40:28 -0700 Subject: [PATCH 5/6] fix(sidebar): split workspace row icon layout --- Sources/ContentView.swift | 95 +++++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 38 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c83698bbb6e0..fbd19d4e9295 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12605,42 +12605,26 @@ private struct TabItemView: View, Equatable { ) } - var body: some View { - let workspaceSnapshot = self.workspaceSnapshot - let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") - let protectedWorkspaceTooltip = String( - localized: "sidebar.pinnedWorkspaceProtected.tooltip", - defaultValue: "Pinned workspace. Closing requires confirmation." - ) - let closeButtonTooltip = workspaceSnapshot.isPinned - ? protectedWorkspaceTooltip - : KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip) - let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") - let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") - let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") - let finderDirectoryPath = WorkspaceFinderDirectoryResolver.path(for: tab) - let finderDirectoryCacheKey = WorkspaceFinderDirectoryCacheKey(path: finderDirectoryPath) - let latestNotificationSubtitle = latestNotificationText - let conversationMessageSubtitle = !settings.hidesAllDetails && settings.iMessageModeEnabled - ? workspaceSnapshot.latestConversationMessage? - .trimmingCharacters(in: .whitespacesAndNewlines) - .nilIfEmpty - : nil - let effectiveSubtitle = latestNotificationSubtitle ?? conversationMessageSubtitle - let detailVisibility = visibleAuxiliaryDetails - let workspaceIconPath = workspaceSnapshot.workspaceIconPath - - HStack(alignment: .top, spacing: workspaceIconPath == nil ? 0 : 10) { - if let workspaceIconPath { - WorkspaceIconView( - iconPath: workspaceIconPath, - reloadToken: workspaceSnapshot.workspaceIconReloadToken, - size: 36 - ) - .padding(.top, 1) - .accessibilityIdentifier("SidebarWorkspaceIcon") - } + @ViewBuilder + private func workspaceIconSection(path: String?, reloadToken: String?) -> some View { + if let path { + WorkspaceIconView( + iconPath: path, + reloadToken: reloadToken, + size: 36 + ) + .padding(.top, 1) + .accessibilityIdentifier("SidebarWorkspaceIcon") + } + } + @ViewBuilder + private func workspaceDetailsContent( + workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, + effectiveSubtitle: String?, + detailVisibility: SidebarWorkspaceAuxiliaryDetailVisibility, + protectedWorkspaceTooltip: String + ) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { if unreadCount > 0 { @@ -12747,7 +12731,6 @@ private struct TabItemView: View, Equatable { .transition(.opacity.combined(with: .move(edge: .top))) } - // Branch + directory row if detailVisibility.showsBranchDirectory { if sidebarBranchVerticalLayout { if !workspaceSnapshot.branchDirectoryLines.isEmpty { @@ -12801,7 +12784,6 @@ private struct TabItemView: View, Equatable { } } - // Pull request rows if detailVisibility.showsPullRequests, !workspaceSnapshot.pullRequestRows.isEmpty { VStack(alignment: .leading, spacing: 1) { ForEach(workspaceSnapshot.pullRequestRows) { pullRequest in @@ -12828,7 +12810,6 @@ private struct TabItemView: View, Equatable { } } - // Ports row if detailVisibility.showsPorts, !workspaceSnapshot.listeningPorts.isEmpty { HStack(spacing: 4) { ForEach(workspaceSnapshot.listeningPorts, id: \.self) { port in @@ -12851,6 +12832,44 @@ private struct TabItemView: View, Equatable { } } .frame(maxWidth: .infinity, alignment: .leading) + } + + var body: some View { + let workspaceSnapshot = self.workspaceSnapshot + let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") + let protectedWorkspaceTooltip = String( + localized: "sidebar.pinnedWorkspaceProtected.tooltip", + defaultValue: "Pinned workspace. Closing requires confirmation." + ) + let closeButtonTooltip = workspaceSnapshot.isPinned + ? protectedWorkspaceTooltip + : KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip) + let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") + let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") + let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") + let finderDirectoryPath = WorkspaceFinderDirectoryResolver.path(for: tab) + let finderDirectoryCacheKey = WorkspaceFinderDirectoryCacheKey(path: finderDirectoryPath) + let latestNotificationSubtitle = latestNotificationText + let conversationMessageSubtitle = !settings.hidesAllDetails && settings.iMessageModeEnabled + ? workspaceSnapshot.latestConversationMessage? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty + : nil + let effectiveSubtitle = latestNotificationSubtitle ?? conversationMessageSubtitle + let detailVisibility = visibleAuxiliaryDetails + let workspaceIconPath = workspaceSnapshot.workspaceIconPath + + HStack(alignment: .top, spacing: workspaceIconPath == nil ? 0 : 10) { + workspaceIconSection( + path: workspaceIconPath, + reloadToken: workspaceSnapshot.workspaceIconReloadToken + ) + workspaceDetailsContent( + workspaceSnapshot: workspaceSnapshot, + effectiveSubtitle: effectiveSubtitle, + detailVisibility: detailVisibility, + protectedWorkspaceTooltip: protectedWorkspaceTooltip + ) } .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.latestLog) .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.progress != nil) From dbcd0c890ef94438fa661eecbc316624f7ce64a9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 18 May 2026 22:52:33 -0700 Subject: [PATCH 6/6] fix(sidebar): address workspace icon review edge cases --- CLI/cmux.swift | 2 +- Resources/Localizable.xcstrings | 2 +- Sources/ContentView.swift | 354 +++++++++--------- .../Sidebar/SidebarAppearanceSupport.swift | 9 +- Sources/Workspace.swift | 3 +- cmuxTests/WorkspaceUnitTests.swift | 3 + 6 files changed, 199 insertions(+), 174 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2bce7dd5d6a6..8e4b3c7dd964 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4657,7 +4657,7 @@ struct CMUXCLI { let icon = ( iconOpt ?? (action == "set_icon" ? (inferredPositional.isEmpty ? nil : inferredPositional) : nil) )?.trimmingCharacters(in: .whitespacesAndNewlines) - if action == "set_icon", (icon?.isEmpty ?? true) { + if action == "set_icon", (icon?.isEmpty ?? true) || iconOpt?.hasPrefix("--") == true { throw CLIError(message: String( localized: "cli.error.workspaceActionSetIconRequiresIcon", defaultValue: "workspace-action set-icon requires --icon <path|emoji:🚀> (or a trailing icon)" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2041d25849d9..bc04eb51ae62 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -112820,7 +112820,7 @@ "alert.emojiIcon.apply": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Apply"}},"bs":{"stringUnit":{"state":"translated","value":"Apply"}},"da":{"stringUnit":{"state":"translated","value":"Apply"}},"de":{"stringUnit":{"state":"translated","value":"Apply"}},"en":{"stringUnit":{"state":"translated","value":"Apply"}},"es":{"stringUnit":{"state":"translated","value":"Apply"}},"fr":{"stringUnit":{"state":"translated","value":"Apply"}},"it":{"stringUnit":{"state":"translated","value":"Apply"}},"ja":{"stringUnit":{"state":"translated","value":"適用"}},"ko":{"stringUnit":{"state":"translated","value":"Apply"}},"nb":{"stringUnit":{"state":"translated","value":"Apply"}},"pl":{"stringUnit":{"state":"translated","value":"Apply"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Apply"}},"ru":{"stringUnit":{"state":"translated","value":"Apply"}},"th":{"stringUnit":{"state":"translated","value":"Apply"}},"tr":{"stringUnit":{"state":"translated","value":"Apply"}},"uk":{"stringUnit":{"state":"translated","value":"Apply"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Apply"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Apply"}}}}, "alert.emojiIcon.cancel": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Cancel"}},"bs":{"stringUnit":{"state":"translated","value":"Cancel"}},"da":{"stringUnit":{"state":"translated","value":"Cancel"}},"de":{"stringUnit":{"state":"translated","value":"Cancel"}},"en":{"stringUnit":{"state":"translated","value":"Cancel"}},"es":{"stringUnit":{"state":"translated","value":"Cancel"}},"fr":{"stringUnit":{"state":"translated","value":"Cancel"}},"it":{"stringUnit":{"state":"translated","value":"Cancel"}},"ja":{"stringUnit":{"state":"translated","value":"キャンセル"}},"ko":{"stringUnit":{"state":"translated","value":"Cancel"}},"nb":{"stringUnit":{"state":"translated","value":"Cancel"}},"pl":{"stringUnit":{"state":"translated","value":"Cancel"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Cancel"}},"ru":{"stringUnit":{"state":"translated","value":"Cancel"}},"th":{"stringUnit":{"state":"translated","value":"Cancel"}},"tr":{"stringUnit":{"state":"translated","value":"Cancel"}},"uk":{"stringUnit":{"state":"translated","value":"Cancel"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Cancel"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Cancel"}}}}, "alert.emojiIcon.message": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"bs":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"da":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"de":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"en":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"es":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"fr":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"it":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコンとして使う絵文字を入力してください。"}},"ko":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"nb":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"pl":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"ru":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"th":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"tr":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"uk":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Enter an emoji to use as the workspace icon."}}}}, - "alert.emojiIcon.invalid.message": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"bs":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"da":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"de":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"en":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"es":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"fr":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"it":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコンとして使う絵文字を1つ以上入力してください。"}},"ko":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"nb":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"pl":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"ru":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"th":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"tr":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"uk":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Enter at least one emoji to use as the workspace icon."}}}}, + "alert.emojiIcon.invalid.message": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"bs":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"da":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"de":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"en":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"es":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"fr":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"it":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースアイコンとして使う絵文字のみを入力してください。"}},"ko":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"nb":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"pl":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"ru":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"th":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"tr":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"uk":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Enter one or more emoji to use as the workspace icon."}}}}, "alert.emojiIcon.invalid.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"bs":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"da":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"de":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"en":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"es":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"fr":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"it":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"ja":{"stringUnit":{"state":"translated","value":"絵文字が必要です"}},"ko":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"nb":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"pl":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"ru":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"th":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"tr":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"uk":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Emoji Required"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Emoji Required"}}}}, "alert.emojiIcon.placeholder": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"🚀"}},"bs":{"stringUnit":{"state":"translated","value":"🚀"}},"da":{"stringUnit":{"state":"translated","value":"🚀"}},"de":{"stringUnit":{"state":"translated","value":"🚀"}},"en":{"stringUnit":{"state":"translated","value":"🚀"}},"es":{"stringUnit":{"state":"translated","value":"🚀"}},"fr":{"stringUnit":{"state":"translated","value":"🚀"}},"it":{"stringUnit":{"state":"translated","value":"🚀"}},"ja":{"stringUnit":{"state":"translated","value":"🚀"}},"ko":{"stringUnit":{"state":"translated","value":"🚀"}},"nb":{"stringUnit":{"state":"translated","value":"🚀"}},"pl":{"stringUnit":{"state":"translated","value":"🚀"}},"pt-BR":{"stringUnit":{"state":"translated","value":"🚀"}},"ru":{"stringUnit":{"state":"translated","value":"🚀"}},"th":{"stringUnit":{"state":"translated","value":"🚀"}},"tr":{"stringUnit":{"state":"translated","value":"🚀"}},"uk":{"stringUnit":{"state":"translated","value":"🚀"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"🚀"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"🚀"}}}}, "alert.emojiIcon.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"bs":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"da":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"de":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"en":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"es":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"fr":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"it":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"ja":{"stringUnit":{"state":"translated","value":"絵文字ワークスペースアイコン"}},"ko":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"nb":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"pl":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"ru":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"th":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"tr":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"uk":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Emoji Workspace Icon"}}}}, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index fbd19d4e9295..4cf99d11af56 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12859,165 +12859,177 @@ private struct TabItemView: View, Equatable { let detailVisibility = visibleAuxiliaryDetails let workspaceIconPath = workspaceSnapshot.workspaceIconPath - HStack(alignment: .top, spacing: workspaceIconPath == nil ? 0 : 10) { - workspaceIconSection( - path: workspaceIconPath, - reloadToken: workspaceSnapshot.workspaceIconReloadToken - ) - workspaceDetailsContent( - workspaceSnapshot: workspaceSnapshot, - effectiveSubtitle: effectiveSubtitle, - detailVisibility: detailVisibility, - protectedWorkspaceTooltip: protectedWorkspaceTooltip - ) - } - .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.latestLog) - .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.progress != nil) - .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.metadataBlocks.count) - .padding(.leading, workspaceIconPath == nil ? 10 : 6) - .padding(.trailing, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(backgroundColor) - .overlay { - RoundedRectangle(cornerRadius: 6) - .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) - } - .overlay(alignment: .leading) { - if showsLeadingRail { - Capsule(style: .continuous) - .fill(railColor) - .frame(width: 3) - .padding(.leading, 4) - .padding(.vertical, 5) - .offset(x: -1) + let visualRow = AnyView( + HStack(alignment: .top, spacing: workspaceIconPath == nil ? 0 : 10) { + workspaceIconSection( + path: workspaceIconPath, + reloadToken: workspaceSnapshot.workspaceIconReloadToken + ) + workspaceDetailsContent( + workspaceSnapshot: workspaceSnapshot, + effectiveSubtitle: effectiveSubtitle, + detailVisibility: detailVisibility, + protectedWorkspaceTooltip: protectedWorkspaceTooltip + ) + } + .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.latestLog) + .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.progress != nil) + .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.metadataBlocks.count) + .padding(.leading, workspaceIconPath == nil ? 10 : 6) + .padding(.trailing, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(backgroundColor) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) } - } - ) - .overlay(alignment: .topTrailing) { - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - ShortcutHintPill(text: workspaceShortcutLabel, fontSize: 10, emphasis: shortcutHintEmphasis) - .offset( - x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), - y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) - ) - .padding(.top, 6) + .overlay(alignment: .leading) { + if showsLeadingRail { + Capsule(style: .continuous) + .fill(railColor) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + .offset(x: -1) + } + } + ) + .overlay(alignment: .topTrailing) { + if showsWorkspaceShortcutHint, let workspaceShortcutLabel { + ShortcutHintPill(text: workspaceShortcutLabel, fontSize: 10, emphasis: shortcutHintEmphasis) + .offset( + x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), + y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) + ) + .padding(.top, 6) + .padding(.trailing, 10) + .shortcutHintTransition() + } else if showCloseButton { + Button(action: { + #if DEBUG + cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + }) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.7)) + } + .buttonStyle(.plain) + .safeHelp(closeButtonTooltip) + .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .padding(.top, 8) .padding(.trailing, 10) - .shortcutHintTransition() - } else if showCloseButton { - Button(action: { - #if DEBUG - cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - }) { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.7)) } - .buttonStyle(.plain) - .safeHelp(closeButtonTooltip) - .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) - .padding(.top, 8) - .padding(.trailing, 10) } - } - .shortcutHintVisibilityAnimation(value: showsWorkspaceShortcutHint) - .padding(.horizontal, 6) - .background { - GeometryReader { proxy in - Color.clear - .onAppear { - rowHeight = max(proxy.size.height, 1) + ) + + let measuredRow = AnyView( + visualRow + .shortcutHintVisibilityAnimation(value: showsWorkspaceShortcutHint) + .padding(.horizontal, 6) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + rowHeight = max(proxy.size.height, 1) + } + .onChange(of: proxy.size.height) { newHeight in + rowHeight = max(newHeight, 1) + } } - .onChange(of: proxy.size.height) { newHeight in - rowHeight = max(newHeight, 1) + } + .contentShape(Rectangle()) + .opacity(isBeingDragged ? 0.6 : 1) + .overlay { + SidebarWorkspaceRowHoverTracker(rowInteractionState: $rowInteractionState) + } + .overlay { + MiddleClickCapture { + #if DEBUG + cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) } - } - } - .contentShape(Rectangle()) - .opacity(isBeingDragged ? 0.6 : 1) - .overlay { - SidebarWorkspaceRowHoverTracker(rowInteractionState: $rowInteractionState) - } - .overlay { - MiddleClickCapture { - #if DEBUG - cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - } - } - .overlay(alignment: .top) { - if showsCenteredTopDropIndicator { - Rectangle() - .fill(cmuxAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) - } - } - .onAppear { - refreshWorkspaceIconDetection(force: true) - refreshWorkspaceSnapshot(force: true) - } - .task(id: finderDirectoryCacheKey) { - let cache = await WorkspaceFinderDirectoryResolver.cache(for: finderDirectoryCacheKey) - guard !Task.isCancelled else { return } - workspaceFinderDirectoryCache = cache - } - .task(id: workspaceFinderDirectoryOpenRequest) { - guard let request = workspaceFinderDirectoryOpenRequest else { return } - await WorkspaceFinderDirectoryOpener.openInFinder(request.directoryURL) - guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } - workspaceFinderDirectoryOpenRequest = nil - } - .onReceive( - tab.sidebarImmediateObservationPublisher - .receive(on: RunLoop.main) - ) { _ in + } + .overlay(alignment: .top) { + if showsCenteredTopDropIndicator { + Rectangle() + .fill(cmuxAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) + } + } + ) + + let observedRow = AnyView( + measuredRow + .onAppear { + refreshWorkspaceIconDetection(force: true) + refreshWorkspaceSnapshot(force: true) + } + .task(id: finderDirectoryCacheKey) { + let cache = await WorkspaceFinderDirectoryResolver.cache(for: finderDirectoryCacheKey) + guard !Task.isCancelled else { return } + workspaceFinderDirectoryCache = cache + } + .task(id: workspaceFinderDirectoryOpenRequest) { + guard let request = workspaceFinderDirectoryOpenRequest else { return } + await WorkspaceFinderDirectoryOpener.openInFinder(request.directoryURL) + guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } + workspaceFinderDirectoryOpenRequest = nil + } + .onReceive( + tab.sidebarImmediateObservationPublisher + .receive(on: RunLoop.main) + ) { _ in #if DEBUG - let description = tab.customDescription ?? "" - cmuxDebugLog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=immediate " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) + let description = tab.customDescription ?? "" + cmuxDebugLog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=immediate " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) #endif - refreshWorkspaceIconDetection() - refreshWorkspaceSnapshot() - } - .onReceive( - tab.sidebarObservationPublisher - .receive(on: RunLoop.main) - // Prompt-time sidebar telemetry can arrive as a short burst - // (pwd, branch, PR, shell state). Coalesce that burst so the - // row redraws once with the settled state instead of blinking. - .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) - ) { _ in + refreshWorkspaceIconDetection() + refreshWorkspaceSnapshot() + } + .onReceive( + tab.sidebarObservationPublisher + .receive(on: RunLoop.main) + // Prompt-time sidebar telemetry can arrive as a short burst + // (pwd, branch, PR, shell state). Coalesce that burst so the + // row redraws once with the settled state instead of blinking. + .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) + ) { _ in #if DEBUG - let description = tab.customDescription ?? "" - cmuxDebugLog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=debounced " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) + let description = tab.customDescription ?? "" + cmuxDebugLog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=debounced " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) #endif - refreshWorkspaceIconDetection() - refreshWorkspaceSnapshot() - } - .onChange(of: settings.autoDetectWorkspaceIcon) { - refreshWorkspaceIconDetection(force: true) - } - .onChange(of: settings) { - refreshWorkspaceSnapshot(force: true) - } - .onDrag { + refreshWorkspaceIconDetection() + refreshWorkspaceSnapshot() + } + .onChange(of: settings.autoDetectWorkspaceIcon) { + refreshWorkspaceIconDetection(force: true) + } + .onChange(of: settings) { + refreshWorkspaceSnapshot(force: true) + } + ) + + observedRow + .onDrag { #if DEBUG cmuxDebugLog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") #endif @@ -14060,34 +14072,36 @@ private struct TabItemView: View, Equatable { } private func promptEmojiIcon(targetIds: [UUID]) { - let alert = NSAlert() - alert.messageText = String(localized: "alert.emojiIcon.title", defaultValue: "Emoji Workspace Icon") - alert.informativeText = String(localized: "alert.emojiIcon.message", defaultValue: "Enter an emoji to use as the workspace icon.") - - let input = NSTextField(string: "") - input.placeholderString = String(localized: "alert.emojiIcon.placeholder", defaultValue: "🚀") - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.emojiIcon.apply", defaultValue: "Apply")) - alert.addButton(withTitle: String(localized: "alert.emojiIcon.cancel", defaultValue: "Cancel")) - alert.window.initialFirstResponder = input - - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { - return - } - guard let normalized = WorkspaceIconValue.normalizedStorageValue("emoji:\(input.stringValue)") else { - showInvalidEmojiIconAlert() - promptEmojiIcon(targetIds: targetIds) + while true { + let alert = NSAlert() + alert.messageText = String(localized: "alert.emojiIcon.title", defaultValue: "Emoji Workspace Icon") + alert.informativeText = String(localized: "alert.emojiIcon.message", defaultValue: "Enter an emoji to use as the workspace icon.") + + let input = NSTextField(string: "") + input.placeholderString = String(localized: "alert.emojiIcon.placeholder", defaultValue: "🚀") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.emojiIcon.apply", defaultValue: "Apply")) + alert.addButton(withTitle: String(localized: "alert.emojiIcon.cancel", defaultValue: "Cancel")) + alert.window.initialFirstResponder = input + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { + return + } + guard let normalized = WorkspaceIconValue.normalizedStorageValue("emoji:\(input.stringValue)") else { + showInvalidEmojiIconAlert() + continue + } + applyTabIcon(normalized, targetIds: targetIds) return } - applyTabIcon(normalized, targetIds: targetIds) } private func showInvalidEmojiIconAlert() { let alert = NSAlert() alert.messageText = String(localized: "alert.emojiIcon.invalid.title", defaultValue: "Emoji Required") - alert.informativeText = String(localized: "alert.emojiIcon.invalid.message", defaultValue: "Enter at least one emoji to use as the workspace icon.") + alert.informativeText = String(localized: "alert.emojiIcon.invalid.message", defaultValue: "Enter one or more emoji to use as the workspace icon.") alert.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) alert.runModal() } diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index b81da13e7ad9..ff82a2cea34c 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -49,7 +49,8 @@ enum WorkspaceIconValue: Equatable, Sendable { if trimmed.hasPrefix(Self.emojiPrefix) { let emoji = String(trimmed.dropFirst(Self.emojiPrefix.count)) .trimmingCharacters(in: .whitespacesAndNewlines) - return emoji.isEmpty ? nil : Self.emojiPrefix + emoji + guard !emoji.isEmpty, emoji.allSatisfy(Self.isEmojiCluster) else { return nil } + return Self.emojiPrefix + emoji } let expanded = NSString(string: trimmed).expandingTildeInPath @@ -59,6 +60,12 @@ enum WorkspaceIconValue: Equatable, Sendable { } private static let emojiPrefix = "emoji:" + + private static func isEmojiCluster(_ character: Character) -> Bool { + character.unicodeScalars.contains { scalar in + scalar.properties.isEmojiPresentation || scalar.value == 0xFE0F + } + } } enum WorkspaceIconDetector { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 419562a52c3d..c83943e70506 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8742,7 +8742,8 @@ final class Workspace: Identifiable, ObservableObject { guard !Task.isCancelled else { return } await MainActor.run { [weak self] in - guard let self, + guard !Task.isCancelled, + let self, self.workspaceIconDetectionDirectory == directory, WorkspaceIconValue.normalizedStorageValue(self.currentDirectory) == directory else { return diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 794e221470f0..894d25e0fb0d 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -201,7 +201,10 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { final class WorkspaceIconTests: XCTestCase { func testIconStorageNormalizesEmojiAndFilePaths() { XCTAssertEqual(WorkspaceIconValue.normalizedStorageValue(" emoji: 🚀 "), "emoji:🚀") + XCTAssertEqual(WorkspaceIconValue.normalizedStorageValue("emoji:👨‍💻"), "emoji:👨‍💻") XCTAssertNil(WorkspaceIconValue.normalizedStorageValue("emoji: ")) + XCTAssertNil(WorkspaceIconValue.normalizedStorageValue("emoji:abc")) + XCTAssertNil(WorkspaceIconValue.normalizedStorageValue("emoji:🚀 app")) XCTAssertNil(WorkspaceIconValue.normalizedStorageValue(" ")) let rawPath = "~/Desktop/cmux-icon.png"