Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import CmuxNextApps
import CmuxNextIcons

/// An app's own page (its pane implementation, `contributes.paneKinds` in
/// manifest v1) as an internal page tab: page id `app:<app id>`, one tab per
Expand All @@ -24,6 +25,8 @@ final class AppPanePage: InternalPageProvider {
if case .symbol(let name)? = app?.manifest.icon { return name }
return pane?.symbol ?? "app"
}
/// The registry's generic app for an app with no symbol of its own.
var icon: IconName? { symbol == "app" ? .appGeneric : nil }

private var app: InstalledApp? { apps.registry.app(appID) }
/// The app's first pane implementation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppKit
import CmuxNextApps
import CmuxNextControl
import CmuxNextDaemon
import CmuxNextIcons
import CmuxNextPages
import Foundation
import Synchronization
Expand Down Expand Up @@ -196,6 +197,7 @@ extension AppsService: InternalPageProvider {
var page: InternalPageID { .appStore }
var title: String { AppStoreModel.title }
var symbol: String { "bag" }
var icon: IconName? { .store }

func makeView(for key: String, in window: WindowController?) -> NSView {
if let page = PageFactory(services: services).appsWebPage(route: Self.storeRoute(appID: nil, installed: false)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxNextIcons
import CmuxNextPages

extension InternalPageID {
Expand All @@ -21,6 +22,7 @@ final class CodeRouterPageTab: InternalPageProvider {
var page: InternalPageID { .coderouter }
var title: String { CodeRouterPageStrings.title }
var symbol: String { "arrow.triangle.branch" }
var icon: IconName? { .accountRouted }

func makeView(for key: String, in window: WindowController?) -> NSView {
guard let services, let view = PageFactory(services: services).coderouterWebPage() else { return NSView() }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
import CmuxNextAgentActivity
import CmuxNextBookmarks
import CmuxNextHistory
import CmuxNextIcons
import CmuxNextRemoteView
import CmuxNextTabs
import Foundation

/// What a browser tab shows where its icon goes (Chromium's `TabIcon`
/// rule): the throbber while the page loads, else the page's favicon, else
/// the browser icon (no favicon yet, none, or its fetch failed). Pure.
/// rule): a cmux page's own icon, the throbber while the page loads, else
/// the page's favicon, else the browser icon (no favicon yet, none, or its
/// fetch failed). Pure.
enum BrowserTabIconState: Equatable {
case throbber
case favicon(TabImage)
case globe
/// A cmux page (`cmux://history`, ...) shown in the browser tab.
case page(IconName)

/// `isLoading` is the live page's load state; a hibernated tab has no
/// live page, so it shows its favicon and never the throbber.
static func resolve(isLoading: Bool, isDormant: Bool, favicon: TabImage?) -> BrowserTabIconState {
/// live page, so it shows its favicon and never the throbber. `url` is
/// the tab's address: a cmux page's address wears that page's icon.
static func resolve(isLoading: Bool, isDormant: Bool, favicon: TabImage?, url: URL? = nil) -> BrowserTabIconState {
if let page = pageIcon(url) { return .page(page) }
if isLoading, !isDormant { return .throbber }
return favicon.map(BrowserTabIconState.favicon) ?? .globe
}

/// The icon of the cmux page at `url`, nil for any other address.
static func pageIcon(_ url: URL?) -> IconName? {
if HistoryPageAddress.matches(url) { return .history }
if BookmarkPageAddress.matches(url) { return .bookmarkManager }
if AgentActivityPageAddress.matches(url) { return .agentActivity }
if url?.scheme?.lowercased() == RemoteViewTabRecord.scheme, url?.host()?.lowercased() == RemoteViewTabRecord.urlHost { return .machineRemote }
return nil
}

/// Applies this state to a strip item (the throbber is the strip's busy
/// spinner in place of the icon; the browser icon is the item's default).
func apply(to item: inout TabItem) {
switch self {
case .throbber: item.isBusy = true
case .favicon(let image): item.icon = .image(image)
case .globe: item.icon = .icon(.browser)
case .page(let name): item.icon = .icon(name)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import CmuxNextActions
import CmuxNextFeed
import CmuxNextIcons

extension InternalPageID {
/// The wide inbox view of the one feed model.
Expand Down Expand Up @@ -31,6 +32,7 @@ final class FeedPageService: InternalPageProvider {
var page: InternalPageID { .inbox }
var title: String { FeedHostView.paneTitle }
var symbol: String { "tray" }
var icon: IconName? { .inbox }

func makeView(for key: String, in window: WindowController?) -> NSView {
guard let feed = services.feed else { return NSView() }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import CmuxNextActions
import CmuxNextIcons
import CmuxNextPages
import CmuxNextSettings

Expand Down Expand Up @@ -35,6 +36,7 @@ final class KeybindingsPageService: InternalPageProvider {
var page: InternalPageID { .keybindings }
var title: String { KeybindingStrings.pageTitle }
var symbol: String { "keyboard" }
var icon: IconName? { .keyboard }

func makeView(for key: String, in window: WindowController?) -> NSView {
let provider = KeybindingsPageProvider(services: services)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxNextIcons

/// Internal pages: cmux's own surfaces (Settings, Debug Settings, the App
/// Store) shown as tabs in a pane instead of windows of their own.
Expand Down Expand Up @@ -45,8 +46,10 @@ protocol InternalPageProvider: AnyObject {
var page: InternalPageID { get }
/// The tab title (localized).
var title: String { get }
/// SF Symbol of the tab.
/// SF Symbol of the tab, drawn when `icon` is nil.
var symbol: String { get }
/// The tab's cmux icon. Default: nil.
var icon: IconName? { get }
/// A new view for the tab `key` (called once per tab, on first show).
/// `window` is the main window the tab opens in, for its theme scope.
func makeView(for key: String, in window: WindowController?) -> NSView
Expand All @@ -58,6 +61,7 @@ protocol InternalPageProvider: AnyObject {
}

extension InternalPageProvider {
var icon: IconName? { nil }
func tabClosed(_ key: String) {}
func title(for key: String) -> String { title }
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import AppKit
import CmuxNextDaemon
import CmuxNextActions
import CmuxNextBridge
import CmuxNextDaemon
import CmuxNextIcons
import CmuxNextTabs

/// Which internal page tabs each pane lists, and their views
Expand Down Expand Up @@ -98,7 +99,13 @@ final class InternalPageTabStore {
func stripItem(_ key: String) -> StripTabItem {
let provider = LocalPageTab.page(of: key).flatMap { providers[$0] }
return StripTabItem(id: StripTabID(key), title: provider?.title(for: key) ?? "", subtitle: nil,
icon: .symbol(provider?.symbol ?? "square.dashed"), isBusy: false)
icon: Self.tabIcon(provider), isBusy: false)
}

/// A page tab's strip icon: the page's cmux icon, else its SF Symbol.
static func tabIcon(_ provider: (any InternalPageProvider)?) -> TabIcon {
guard let provider else { return .icon(.placeholder) }
return provider.icon.map(TabIcon.icon) ?? .symbol(provider.symbol)
}

/// The tab's view, made on first show.
Expand All @@ -124,7 +131,7 @@ final class InternalPageTabStore {
/// A store page tab's strip title and icon, from the page it shows.
func storeTabItem(_ tab: TabModel) -> (title: String, icon: TabIcon)? {
guard let page = tab.page.map(InternalPageID.init(rawValue:)), let provider = providers[page] else { return nil }
return (provider.title(for: storeKeys[tab.id] ?? ""), .symbol(provider.symbol))
return (provider.title(for: storeKeys[tab.id] ?? ""), Self.tabIcon(provider))
}

/// The view of store page tab `tab` (its `page` source), made on first
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxNextIcons
import CmuxNextPages
import CmuxNextUpdater

Expand All @@ -20,6 +21,7 @@ final class ChangelogPageTab: InternalPageProvider {
var page: InternalPageID { .changelog }
var title: String { ChangelogPageStrings.title }
var symbol: String { "sparkles" }
var icon: IconName? { .fileText }

func makeView(for key: String, in window: WindowController?) -> NSView {
guard let services, let view = PageFactory(services: services).changelogPage() else { return NSView() }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import CmuxNextBridge
import CmuxNextIcons
import CmuxNextPages
import CmuxNextSettings

Expand Down Expand Up @@ -61,6 +62,7 @@ final class DiffPageService: InternalPageProvider {
var page: InternalPageID { .diff }
var title: String { DiffPageStrings.tabTitle }
var symbol: String { "plusminus" }
var icon: IconName? { .diffFile }
var recents: DiffRecents { recentsStore }

func title(for key: String) -> String { tabs[key]?.repository?.name ?? title }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CmuxNextDesign
import CmuxNextIcons
import CmuxNextPages
import Foundation

Expand Down Expand Up @@ -60,6 +61,13 @@ nonisolated enum FilePageKind: String, Sendable, CaseIterable {
}
}

var icon: IconName {
switch self {
case .markdown: .fileText
case .editor: .code
}
}

var title: String {
switch self {
case .markdown: FilePageStrings.markdownTitle
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import CmuxNextBridge
import CmuxNextDesign
import CmuxNextIcons
import CmuxNextPages
import CmuxNextSettings
import Foundation
Expand Down Expand Up @@ -59,6 +60,7 @@ final class FilePageService: InternalPageProvider {
var page: InternalPageID { kind.page }
var title: String { kind.title }
var symbol: String { kind.symbol }
var icon: IconName? { kind.icon }

func title(for key: String) -> String { tabs[key]?.file?.lastPathComponent ?? title }

Expand Down
11 changes: 6 additions & 5 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ final class PaneController: SurfacePresenter, PresentablePane {
} else {
item.profileBadge = services.browserProfiles.tabBadge(for: tab, workspaceID: workspaceID)
}
browserIcon(key: tab.id, recordFavicon: incognito ? nil : tab.faviconURL).apply(to: &item)
browserIcon(key: tab.id, recordFavicon: incognito ? nil : tab.faviconURL, recordURL: tab.url).apply(to: &item)
}
return item
}
Expand All @@ -158,14 +158,15 @@ final class PaneController: SurfacePresenter, PresentablePane {
generation: store.generation?.rawValue, surfaces: pane.tabs.map(\.surface.rawValue))
}

/// The favicon, throbber or globe of browser tab `key`: its live page's
/// load state and favicon, else the favicon its record names.
private func browserIcon(key: String, recordFavicon: String?) -> BrowserTabIconState {
/// The page icon, favicon, throbber or globe of browser tab `key`: its live page's
/// address, load state and favicon, else the address and favicon its record names.
private func browserIcon(key: String, recordFavicon: String?, recordURL: String? = nil) -> BrowserTabIconState {
_ = services.cache.pageInstalls.revision
let page = services.cache.existingBrowser(key)?.tab.state
let address = page.map { $0.faviconURL?.absoluteString } ?? recordFavicon
let image = services.favicons.image(for: address, profile: services.browserProfiles.engineProfile(forTab: key))
return .resolve(isLoading: page?.isLoading ?? false, isDormant: services.cache.dormantTabs.contains(key), favicon: image)
let url = page?.url ?? recordURL.flatMap(URL.init(string:))
return .resolve(isLoading: page?.isLoading ?? false, isDormant: services.cache.dormantTabs.contains(key), favicon: image, url: url)
}

/// Pushes daemon truth into the strip. `force` resets optimistic strip
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import CmuxNextActions
import CmuxNextIcons
import CmuxNextPages
import Foundation

Expand Down Expand Up @@ -46,6 +47,7 @@ final class PasswordsPageService: InternalPageProvider {
var page: InternalPageID { .passwords }
var title: String { PasswordStrings.pageTitle }
var symbol: String { "key" }
var icon: IconName? { .securityLock }

func makeView(for key: String, in window: WindowController?) -> NSView {
let provider = PasswordsPageProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppKit
import CmuxNextActions
import CmuxNextDaemon
import CmuxNextDesign
import CmuxNextIcons
import CmuxNextPages
import CmuxNextSettings
import CmuxNextSettingsWindow
Expand Down Expand Up @@ -96,6 +97,7 @@ final class SettingsWindowService: InternalPageProvider {
var page: InternalPageID { .settings }
var title: String { SettingsDeepLink.pageTitle }
var symbol: String { "gearshape" }
var icon: IconName? { .settings }

/// The kept page when no other tab shows it, else a new one (a second window's tab).
func makeView(for key: String, in window: WindowController?) -> NSView {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppKit
import CmuxNextBookmarks
import CmuxNextBrowser
import CmuxNextDaemon
import CmuxNextIcons

extension InternalPageID {
/// History as a top page (TOP-SECTION-ITEMS-ARE-PAGES Q3).
Expand All @@ -23,6 +24,7 @@ final class HistoryTopPage: InternalPageProvider {
var page: InternalPageID { .history }
var title: String { HistoryPageStrings.title }
var symbol: String { "clock" }
var icon: IconName? { .history }

func makeView(for key: String, in window: WindowController?) -> NSView {
guard let services else { return NSView() }
Expand Down Expand Up @@ -52,6 +54,7 @@ final class BookmarksTopPage: InternalPageProvider {
var page: InternalPageID { .bookmarks }
var title: String { BookmarkStrings.pageTitle }
var symbol: String { "book" }
var icon: IconName? { .bookmarkManager }

func makeView(for key: String, in window: WindowController?) -> NSView {
guard let services else { return NSView() }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import AppKit
import CmuxNextIcons
import CmuxNextTabs
import Testing
@testable import CmuxNextApp

/// Internal page tabs (Settings, History, Diff, ...) draw their page's cmux
/// icon in the tab strip; a page with no registry icon (a third-party app's
/// pane) keeps its SF Symbol.
@MainActor @Suite struct InternalPageTabIconTests {
final class Provider: InternalPageProvider {
let icon: IconName?
init(icon: IconName?) { self.icon = icon }
var page: InternalPageID { InternalPageID(rawValue: "icon-test") }
var title: String { "Test" }
var symbol: String { "square" }
func makeView(for key: String, in window: WindowController?) -> NSView { NSView() }
}

@Test func aPageWithARegistryIconDrawsIt() {
#expect(InternalPageTabStore.tabIcon(Provider(icon: .settings)) == .icon(.settings))
#expect(InternalPageTabStore.tabIcon(Provider(icon: nil)) == .symbol("square"))
#expect(InternalPageTabStore.tabIcon(nil) == .icon(.placeholder))
}

@Test func filePagesNameTheirKind() {
#expect(FilePageKind.markdown.icon == .fileText)
#expect(FilePageKind.editor.icon == .code)
}

/// cmux pages shown in a browser tab (History, Bookmarks, Agent activity,
/// a remote view) wear their page's icon, not the globe, and no throbber.
@Test func cmuxAddressesWearTheirPageIcon() {
let page = { (url: String) in BrowserTabIconState.resolve(isLoading: true, isDormant: false, favicon: nil, url: URL(string: url)) }
#expect(page("cmux://history") == .page(.history))
#expect(page("cmux://bookmarks/") == .page(.bookmarkManager))
#expect(page("cmux://agent-activity") == .page(.agentActivity))
#expect(page("cmux://remote-view?host=a") == .page(.machineRemote))
#expect(page("https://example.com") == .throbber)
var item = TabItem(id: TabID("h"), title: "History")
BrowserTabIconState.page(.history).apply(to: &item)
#expect(item.icon == .icon(.history))
}
}
Loading