From cdd30e32ddb26e9edb0d3b6b83ed0289f65fc5f9 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 6 Jun 2026 11:25:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20refine=20sidebar=20and?= =?UTF-8?q?=20terminal=20tabs=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/MoriUI/SidebarContainerView.swift | 8 +- .../Sources/MoriUI/WorktreeSidebarView.swift | 155 ++++++++-------- Sources/Mori/App/AppDelegate.swift | 24 +++ Sources/Mori/App/HostingControllers.swift | 16 +- .../Mori/App/RootSplitViewController.swift | 15 +- .../Mori/App/TerminalAreaViewController.swift | 171 +++++++++++++++++- 6 files changed, 301 insertions(+), 88 deletions(-) diff --git a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift index f9d8604b..069cfad4 100644 --- a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift +++ b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift @@ -30,6 +30,7 @@ public struct SidebarContainerView: View { private let onSendKeys: ((String, String) -> Void)? private let onUpdateProject: ((Project) -> Void)? private let onReorderProjects: (([UUID]) -> Void)? + private let isSidebarCollapsed: Bool public init( projects: [Project] = [], @@ -53,7 +54,8 @@ public struct SidebarContainerView: View { onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil, onSendKeys: ((String, String) -> Void)? = nil, onUpdateProject: ((Project) -> Void)? = nil, - onReorderProjects: (([UUID]) -> Void)? = nil + onReorderProjects: (([UUID]) -> Void)? = nil, + isSidebarCollapsed: Bool = false ) { self.projects = projects self.selectedProjectId = selectedProjectId @@ -77,6 +79,7 @@ public struct SidebarContainerView: View { self.onSendKeys = onSendKeys self.onUpdateProject = onUpdateProject self.onReorderProjects = onReorderProjects + self.isSidebarCollapsed = isSidebarCollapsed } /// Shared Cmd-hold shortcut hint monitor — one instance for the entire sidebar. @@ -106,7 +109,8 @@ public struct SidebarContainerView: View { onRequestPaneOutput: onRequestPaneOutput, onSendKeys: onSendKeys, onUpdateProject: onUpdateProject, - onReorderProjects: onReorderProjects + onReorderProjects: onReorderProjects, + isSidebarCollapsed: isSidebarCollapsed ) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift index a3e1f4fb..bdc9c6dd 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift @@ -27,6 +27,7 @@ public struct WorktreeSidebarView: View { private let onUpdateProject: ((Project) -> Void)? private let onReorderProjects: (([UUID]) -> Void)? private let shortcutHintsVisible: Bool + private let isSidebarCollapsed: Bool public init( projects: [Project] = [], @@ -51,7 +52,8 @@ public struct WorktreeSidebarView: View { onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil, onSendKeys: ((String, String) -> Void)? = nil, onUpdateProject: ((Project) -> Void)? = nil, - onReorderProjects: (([UUID]) -> Void)? = nil + onReorderProjects: (([UUID]) -> Void)? = nil, + isSidebarCollapsed: Bool = false ) { self.projects = projects self.selectedProjectId = selectedProjectId @@ -76,6 +78,7 @@ public struct WorktreeSidebarView: View { self.onUpdateProject = onUpdateProject self.onReorderProjects = onReorderProjects self.shortcutHintsVisible = shortcutHintsVisible + self.isSidebarCollapsed = isSidebarCollapsed } private struct ActiveAgentPaneItem: Identifiable { @@ -155,11 +158,17 @@ public struct WorktreeSidebarView: View { } public var body: some View { + if isSidebarCollapsed { + collapsedBody + } else { + expandedBody + } + } + + private var expandedBody: some View { VStack(spacing: 0) { ScrollView(.vertical) { LazyVStack(alignment: .leading, spacing: MoriTokens.Spacing.md) { - summaryStrip - activeWorktreeSection projectsSectionHeader if !isProjectsSectionCollapsed { @@ -203,6 +212,23 @@ public struct WorktreeSidebarView: View { } } + private var collapsedBody: some View { + VStack(spacing: MoriTokens.Spacing.md) { + ScrollView(.vertical) { + LazyVStack(spacing: MoriTokens.Spacing.lg) { + ForEach(sortedProjects) { project in + collapsedProjectButton(project) + } + } + .padding(.top, MoriTokens.Spacing.lg) + .padding(.bottom, MoriTokens.Spacing.sm) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + // MARK: - Project Section @State private var hoveredProjectId: UUID? @@ -390,92 +416,73 @@ public struct WorktreeSidebarView: View { } } + private func collapsedProjectButton(_ project: Project) -> some View { + let isSelectedProject = project.id == selectedProjectId + + return Button { + onSelectProject?(project.id) + } label: { + ProjectLetterTile(project: project) + .padding(MoriTokens.Spacing.xs) + .background( + RoundedRectangle(cornerRadius: MoriTokens.Radius.small) + .fill(isSelectedProject ? MoriTokens.Color.active.opacity(0.16) : Color.clear) + ) + .overlay { + RoundedRectangle(cornerRadius: MoriTokens.Radius.small) + .strokeBorder( + isSelectedProject ? MoriTokens.Color.active.opacity(0.28) : Color.clear, + lineWidth: 1 + ) + } + } + .buttonStyle(.plain) + .help(project.name) + .accessibilityLabel(project.name) + } + // MARK: - Worktree Row @ViewBuilder private func worktreeRow(_ worktree: Worktree) -> some View { let isSelected = worktree.id == selectedWorktreeId let worktreeWindows = allWindows(for: worktree) - let detailWindows = visibleDetailWindows(for: worktree) let agentName = worktreeWindows.first(where: { $0.detectedAgent != nil || $0.agentState != .none })?.detectedAgent - VStack(alignment: .leading, spacing: 0) { - WorktreeRowView( - worktree: worktree, - agentName: agentName, - isSelected: isSelected, - onSelect: { onSelectWorktree(worktree.id) }, - onRemove: onRemoveWorktree.map { remove in { remove(worktree.id) } } - ) - .contextMenu { - let editors = EditorLauncher.installed - if !editors.isEmpty { - ForEach(editors) { editor in - Button { - editor.open(path: worktree.path) - } label: { - Label("Open in \(editor.name)", systemImage: editor.icon) - } - } - Divider() - } - - Button { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path) - } label: { - Label("Reveal in Finder", systemImage: "folder") - } - - if !worktree.isMainWorktree, let onRemove = onRemoveWorktree { - Divider() - Button(role: .destructive) { - onRemove(worktree.id) + WorktreeRowView( + worktree: worktree, + agentName: agentName, + isSelected: isSelected, + onSelect: { onSelectWorktree(worktree.id) }, + onRemove: onRemoveWorktree.map { remove in { remove(worktree.id) } } + ) + .contextMenu { + let editors = EditorLauncher.installed + if !editors.isEmpty { + ForEach(editors) { editor in + Button { + editor.open(path: worktree.path) } label: { - Label("Remove Worktree…", systemImage: "trash") + Label("Open in \(editor.name)", systemImage: editor.icon) } } + Divider() } - // Show only the most relevant window details for the selected worktree. - if isSelected, !detailWindows.isEmpty { - TreeConnectorGroup(data: detailWindows) { window in - let globalIdx = globalWindowIndices[window.tmuxWindowId] - Group { - if window.detectedAgent != nil || window.agentState != .none { - AgentWindowRowView( - window: window, - projectName: projects.first(where: { $0.id == worktree.projectId })?.name ?? "", - worktreeName: worktree.name, - isSelected: isSelected && window.tmuxWindowId == selectedWindowId, - shortcutIndex: globalIdx, - shortcutHintsVisible: shortcutHintsVisible, - onSelect: { onSelectWindow(window.tmuxWindowId) }, - onRequestPaneOutput: onRequestPaneOutput, - onSendKeys: onSendKeys - ) - } else { - WindowRowView( - window: window, - isActive: isSelected && window.tmuxWindowId == selectedWindowId, - shortcutIndex: globalIdx, - shortcutHintsVisible: shortcutHintsVisible, - onSelect: { onSelectWindow(window.tmuxWindowId) }, - onRequestPaneOutput: onRequestPaneOutput, - onSendKeys: onSendKeys - ) - } - } - .contextMenu { - if let onCloseWindow { - Button(role: .destructive) { - onCloseWindow(window.tmuxWindowId) - } label: { - Label("Close Tab", systemImage: "xmark") - } - } - } + Button { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path) + } label: { + Label("Reveal in Finder", systemImage: "folder") + } + + if !worktree.isMainWorktree, let onRemove = onRemoveWorktree { + Divider() + Button(role: .destructive) { + onRemove(worktree.id) + } label: { + Label("Remove Worktree…", systemImage: "trash") } } } diff --git a/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index ff8b51bd..35ab2e13 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -246,6 +246,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.sidebarController = sidebarController sidebarController.updateAppearance(themeInfo: themeInfo) + terminalArea.configureTabs( + appState: state, + onSelectWindow: { [weak manager, weak self] windowId in + manager?.selectWindow(windowId) + self?.updateWindowTitle() + self?.syncVisibleCompanionToolToSelection() + }, + onCloseWindow: { [weak manager] windowId in + guard let manager else { return } + Task { @MainActor in + await manager.closeWindow(windowId: windowId) + } + }, + onCreateWindow: { [weak manager] in + guard let manager else { return } + Task { @MainActor in + await manager.createNewWindow() + } + } + ) + let splitVC = RootSplitViewController( sidebarController: sidebarController, contentController: terminalArea, @@ -256,6 +277,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent splitVC.onCompanionWidthChanged = { [weak self] width in self?.companionToolState.width = width } + splitVC.onSidebarCollapsedChanged = { [weak sidebarController] isCollapsed in + sidebarController?.setSidebarCollapsed(isCollapsed) + } splitVC.updateCompanionPane(state: companionToolState) windowController.onToggleSidebar = { [weak splitVC] in diff --git a/Sources/Mori/App/HostingControllers.swift b/Sources/Mori/App/HostingControllers.swift index 4523ee44..9ff152ea 100644 --- a/Sources/Mori/App/HostingControllers.swift +++ b/Sources/Mori/App/HostingControllers.swift @@ -6,11 +6,18 @@ import MoriUI // MARK: - Sidebar Hosting (unified: project picker + worktrees + actions) +@MainActor +@Observable +final class SidebarLayoutState { + var isCollapsed = false +} + /// Wraps SidebarContainerView in an NSHostingController, observing AppState. @MainActor final class SidebarHostingController: NSHostingController { private let appState: AppState + private let layoutState = SidebarLayoutState() init( appState: AppState, @@ -33,6 +40,7 @@ final class SidebarHostingController: NSHostingController { self.appState = appState let rootView = SidebarContentView( appState: appState, + layoutState: layoutState, onSelectProject: onSelectProject, onSelectWorktree: onSelectWorktree, onSelectWindow: onSelectWindow, @@ -70,11 +78,16 @@ final class SidebarHostingController: NSHostingController { // Force SwiftUI to re-render with the updated appearance context. view.needsDisplay = true } + + func setSidebarCollapsed(_ isCollapsed: Bool) { + layoutState.isCollapsed = isCollapsed + } } /// Bindable wrapper that reads AppState observables into SidebarContainerView. struct SidebarContentView: View { @Bindable var appState: AppState + @Bindable var layoutState: SidebarLayoutState let onSelectProject: (UUID) -> Void let onSelectWorktree: (UUID) -> Void let onSelectWindow: (String) -> Void @@ -114,7 +127,8 @@ struct SidebarContentView: View { onRequestPaneOutput: onRequestPaneOutput, onSendKeys: onSendKeys, onUpdateProject: onUpdateProject, - onReorderProjects: onReorderProjects + onReorderProjects: onReorderProjects, + isSidebarCollapsed: layoutState.isCollapsed ) } } diff --git a/Sources/Mori/App/RootSplitViewController.swift b/Sources/Mori/App/RootSplitViewController.swift index 636baf75..ea9cc8e1 100644 --- a/Sources/Mori/App/RootSplitViewController.swift +++ b/Sources/Mori/App/RootSplitViewController.swift @@ -13,6 +13,7 @@ final class RootSplitViewController: NSViewController { private(set) var companionController: NSViewController private static let sidebarWidthKey = "MoriSidebarWidth" + private static let sidebarCollapsedWidth: CGFloat = 52 private static let companionWidthKey = "MoriCompanionToolPaneWidth" private static let sidebarMinWidth: CGFloat = 180 private static let sidebarMaxWidth: CGFloat = 400 @@ -32,6 +33,7 @@ final class RootSplitViewController: NSViewController { private var toolPaneState = CompanionToolPaneState() var onCompanionWidthChanged: ((CGFloat) -> Void)? + var onSidebarCollapsedChanged: ((Bool) -> Void)? init( sidebarController: NSViewController, @@ -91,8 +93,8 @@ final class RootSplitViewController: NSViewController { private func updateLayout() { let bounds = view.bounds - let sidebarWidth = collapsed ? 0 : self.sidebarWidth - let sidebarDividerWidth: CGFloat = collapsed ? 0 : 1 + let sidebarWidth = collapsed ? Self.sidebarCollapsedWidth : self.sidebarWidth + let sidebarDividerWidth: CGFloat = 1 let availableWidth = bounds.width - sidebarWidth - sidebarDividerWidth let companionVisible = toolPaneState.isVisible let companionDividerWidth: CGFloat = companionVisible ? 1 : 0 @@ -116,8 +118,8 @@ final class RootSplitViewController: NSViewController { height: bounds.height ) - sidebarContainer.isHidden = collapsed - sidebarDividerView.isHidden = collapsed + sidebarContainer.isHidden = false + sidebarDividerView.isHidden = false contentContainer.isHidden = false companionContainer.isHidden = !companionVisible companionDividerView.isHidden = !companionVisible @@ -209,6 +211,7 @@ final class RootSplitViewController: NSViewController { ctx.duration = 0.2 ctx.allowsImplicitAnimation = true collapsed.toggle() + onSidebarCollapsedChanged?(collapsed) updateLayout() } } @@ -238,11 +241,11 @@ final class RootSplitViewController: NSViewController { } private var sidebarVisibleWidth: CGFloat { - collapsed ? 0 : sidebarWidth + collapsed ? Self.sidebarCollapsedWidth : sidebarWidth } private var sidebarDividerVisibleWidth: CGFloat { - collapsed ? 0 : 1 + 1 } private func embed(_ vc: NSViewController, in container: NSView) { diff --git a/Sources/Mori/App/TerminalAreaViewController.swift b/Sources/Mori/App/TerminalAreaViewController.swift index 6ab9fc7d..0923e8f2 100644 --- a/Sources/Mori/App/TerminalAreaViewController.swift +++ b/Sources/Mori/App/TerminalAreaViewController.swift @@ -1,7 +1,9 @@ import AppKit +import SwiftUI import MoriCore import MoriTerminal import MoriTmux +import MoriUI #if compiler(>=6.2) @available(macOS 26.0, *) @@ -74,7 +76,10 @@ private final class WorkspaceGlassBackgroundView: NSView { /// running `tmux new-session -A -s ` to attach-or-create. @MainActor final class TerminalAreaViewController: NSViewController { + private static let tabBarHeight: CGFloat = 34 + private var glassBackgroundView: NSView? + private var tabsHostingController: NSHostingController? // MARK: - Dependencies @@ -134,6 +139,7 @@ final class TerminalAreaViewController: NSViewController { let container = NSView() container.wantsLayer = true self.view = container + installTabsViewIfNeeded() updateAppearance(themeInfo: themeInfo, isKeyWindow: true) showEmptyState() } @@ -142,10 +148,29 @@ final class TerminalAreaViewController: NSViewController { super.viewDidLayout() // Notify the terminal host about resize if let surface = currentSurface { - terminalHost.surfaceDidResize(surface, to: view.bounds.size) + terminalHost.surfaceDidResize(surface, to: surface.bounds.size) } } + func configureTabs( + appState: AppState, + onSelectWindow: @escaping (String) -> Void, + onCloseWindow: @escaping (String) -> Void, + onCreateWindow: @escaping () -> Void + ) { + let tabsView = TerminalTabsBarView( + appState: appState, + onSelectWindow: onSelectWindow, + onCloseWindow: onCloseWindow, + onCreateWindow: onCreateWindow + ) + let controller = NSHostingController(rootView: tabsView) + controller.sizingOptions = [] + tabsHostingController = controller + addChild(controller) + installTabsViewIfNeeded() + } + func updateAppearance(themeInfo: GhosttyThemeInfo, isKeyWindow: Bool) { view.layer?.backgroundColor = backgroundColor(for: themeInfo).cgColor updateGlassEffectIfNeeded(themeInfo: themeInfo, isKeyWindow: isKeyWindow) @@ -370,7 +395,7 @@ final class TerminalAreaViewController: NSViewController { view.addSubview(container) NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: view.topAnchor), + container.topAnchor.constraint(equalTo: terminalContentTopAnchor), container.bottomAnchor.constraint(equalTo: view.bottomAnchor), container.leadingAnchor.constraint(equalTo: view.leadingAnchor), container.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -384,6 +409,29 @@ final class TerminalAreaViewController: NSViewController { emptyStateView = nil } + private var terminalContentTopAnchor: NSLayoutYAxisAnchor { + if let tabsView = tabsHostingController?.view, tabsView.superview === view { + return tabsView.bottomAnchor + } + return view.topAnchor + } + + private func installTabsViewIfNeeded() { + guard isViewLoaded, + let controller = tabsHostingController, + controller.view.superview == nil else { return } + + let tabsView = controller.view + tabsView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tabsView) + NSLayoutConstraint.activate([ + tabsView.topAnchor.constraint(equalTo: view.topAnchor), + tabsView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tabsView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tabsView.heightAnchor.constraint(equalToConstant: Self.tabBarHeight), + ]) + } + @objc private func emptyStateButtonClicked() { onCreateSession?() } @@ -459,7 +507,7 @@ final class TerminalAreaViewController: NSViewController { surface.translatesAutoresizingMaskIntoConstraints = false view.addSubview(surface) NSLayoutConstraint.activate([ - surface.topAnchor.constraint(equalTo: view.topAnchor), + surface.topAnchor.constraint(equalTo: terminalContentTopAnchor), surface.bottomAnchor.constraint(equalTo: view.bottomAnchor), surface.leadingAnchor.constraint(equalTo: view.leadingAnchor), surface.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -467,7 +515,7 @@ final class TerminalAreaViewController: NSViewController { currentSessionKey = identity currentSurface = surface - terminalHost.surfaceDidResize(surface, to: view.bounds.size) + terminalHost.surfaceDidResize(surface, to: surface.bounds.size) if focus { focusCurrentSurface() } @@ -488,7 +536,8 @@ final class TerminalAreaViewController: NSViewController { /// Defensive cleanup in case a dead surface view was not tracked as current. private func removeResidualTerminalSubviews() { - for subview in view.subviews where subview !== emptyStateView { + let tabsView = tabsHostingController?.view + for subview in view.subviews where subview !== emptyStateView && subview !== tabsView { if subview !== currentSurface { subview.removeFromSuperview() } @@ -571,3 +620,115 @@ final class TerminalAreaViewController: NSViewController { } } } + +private struct TerminalTabsBarView: View { + @Bindable var appState: AppState + let onSelectWindow: (String) -> Void + let onCloseWindow: (String) -> Void + let onCreateWindow: () -> Void + + private var windows: [RuntimeWindow] { + appState.windowsForSelectedWorktree + } + + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + ForEach(windows) { window in + terminalTab(for: window) + } + + Button(action: onCreateWindow) { + Image(systemName: "plus") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 28, height: 26) + .foregroundStyle(MoriTokens.Color.muted) + .background(Color.primary.opacity(MoriTokens.Opacity.quiet)) + .clipShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.small)) + } + .buttonStyle(.plain) + .help(String.localized("New Tab")) + .accessibilityLabel(String.localized("New Tab")) + .padding(.leading, MoriTokens.Spacing.sm) + .padding(.bottom, 5) + + Spacer(minLength: 0) + } + .padding(.leading, MoriTokens.Spacing.xl) + .padding(.trailing, MoriTokens.Spacing.lg) + .background(Color.clear) + } + + private func terminalTab(for window: RuntimeWindow) -> some View { + let isSelected = window.tmuxWindowId == appState.uiState.selectedWindowId + + return HStack(spacing: MoriTokens.Spacing.sm) { + Circle() + .fill(tabDotColor(for: window, isSelected: isSelected)) + .frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot) + + Text(tabTitle(for: window)) + .font(.system(size: 13, weight: isSelected ? .semibold : .medium)) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(isSelected ? Color.primary : MoriTokens.Color.muted) + .frame(maxWidth: .infinity, alignment: .leading) + + if isSelected { + Button(action: { onCloseWindow(window.tmuxWindowId) }) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(MoriTokens.Color.muted) + .frame(width: 16, height: 16) + .background(Color.primary.opacity(MoriTokens.Opacity.quiet)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help(String.localized("Close Tab")) + .accessibilityLabel(String.localized("Close Tab")) + } + } + .padding(.horizontal, MoriTokens.Spacing.lg) + .frame(width: 170, height: 32) + .contentShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.medium, style: .continuous)) + .onTapGesture { + onSelectWindow(window.tmuxWindowId) + } + .background( + RoundedRectangle( + cornerRadius: MoriTokens.Radius.medium, + style: .continuous + ) + .fill(isSelected ? Color.primary.opacity(0.11) : Color.primary.opacity(0.055)) + ) + .overlay(alignment: .bottom) { + if isSelected { + Rectangle() + .fill((Color(nsColor: .windowBackgroundColor))) + .frame(height: 1) + } + } + .overlay { + RoundedRectangle(cornerRadius: MoriTokens.Radius.medium, style: .continuous) + .strokeBorder(isSelected ? Color.primary.opacity(0.16) : Color.clear, lineWidth: 1) + } + .padding(.top, 2) + } + + private func tabTitle(for window: RuntimeWindow) -> String { + if !window.title.isEmpty { + return window.title + } + return String.localized("Window \(window.tmuxWindowIndex)") + } + + private func tabDotColor(for window: RuntimeWindow, isSelected: Bool) -> Color { + if isSelected { return MoriTokens.Color.active } + if window.detectedAgent != nil || window.agentState != .none { return MoriTokens.Color.info } + if window.hasUnreadOutput { return MoriTokens.Color.attention } + switch window.tag { + case .server: return MoriTokens.Color.success + case .agent: return MoriTokens.Color.info + default: return MoriTokens.Color.inactive + } + } +} From 3360762147d6065c2ae3adde3061deb4624f1933 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 6 Jun 2026 11:34:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=92=84=20style:=20tighten=20sidebar?= =?UTF-8?q?=20density?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/MoriUI/WorktreeRowView.swift | 35 ++++++++++--------- .../Sources/MoriUI/WorktreeSidebarView.swift | 27 +++++++------- .../Mori/App/RootSplitViewController.swift | 8 ++--- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift index dbaf189d..925e6706 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift @@ -34,22 +34,18 @@ public struct WorktreeRowView: View { public var body: some View { Button(action: onSelect) { - HStack(alignment: .center, spacing: MoriTokens.Spacing.lg) { - iconView - - VStack(alignment: .leading, spacing: MoriTokens.Spacing.xxs) { - HStack(spacing: MoriTokens.Spacing.sm) { - Text(worktree.name) - .font(MoriTokens.Font.rowTitle) - .foregroundStyle(isSelected ? Color.primary : Color.primary.opacity(0.9)) - .lineLimit(1) - - if isSelected { - selectedAgentLabel - } - } + HStack(alignment: .center, spacing: MoriTokens.Spacing.md) { + Circle() + .fill(statusDotColor) + .frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot) - subtitleLine + Text(worktree.name) + .font(MoriTokens.Font.rowTitle) + .foregroundStyle(isSelected ? Color.primary : Color.primary.opacity(0.9)) + .lineLimit(1) + + if isSelected { + selectedAgentLabel } Spacer(minLength: 0) @@ -61,7 +57,7 @@ public struct WorktreeRowView: View { .transition(.opacity) } } - .padding(.vertical, 9) + .padding(.vertical, 6) .padding(.horizontal, MoriTokens.Spacing.lg) .contentShape(Rectangle()) } @@ -90,6 +86,13 @@ public struct WorktreeRowView: View { } } + private var statusDotColor: Color { + if worktree.agentState == .waitingForInput { return MoriTokens.Color.attention } + if worktree.agentState == .running { return MoriTokens.Color.success } + if worktree.status == .active { return MoriTokens.Color.success } + return MoriTokens.Color.inactive + } + private var iconView: some View { ZStack { RoundedRectangle(cornerRadius: MoriTokens.Icon.worktreeBoxRadius) diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift index bdc9c6dd..e3e96e07 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift @@ -298,9 +298,8 @@ public struct WorktreeSidebarView: View { .transition(.opacity) } } - .padding(.horizontal, MoriTokens.Spacing.lg) - .padding(.top, MoriTokens.Sidebar.projectHeaderTop) - .padding(.bottom, MoriTokens.Spacing.md) + .padding(.horizontal, MoriTokens.Spacing.md) + .padding(.vertical, 7) .background( RoundedRectangle(cornerRadius: MoriTokens.Radius.small) .fill( @@ -323,10 +322,10 @@ public struct WorktreeSidebarView: View { : Color.primary.opacity(MoriTokens.Opacity.subtle) ) .frame(height: 1) - .padding(.horizontal, MoriTokens.Spacing.lg) - .padding(.top, MoriTokens.Spacing.sm) + .padding(.horizontal, MoriTokens.Spacing.md) + .padding(.top, MoriTokens.Spacing.xs) - VStack(alignment: .leading, spacing: MoriTokens.Spacing.xs) { + VStack(alignment: .leading, spacing: 3) { if projectWorktrees.isEmpty, project.id == selectedProjectId { Text("No worktrees") .font(MoriTokens.Font.caption) @@ -339,26 +338,26 @@ public struct WorktreeSidebarView: View { worktreeRow(worktree) } } - .padding(.top, MoriTokens.Spacing.sm) - .padding(.bottom, MoriTokens.Spacing.sm) + .padding(.top, MoriTokens.Spacing.xs) + .padding(.bottom, MoriTokens.Spacing.xs) } } - .padding(.horizontal, MoriTokens.Spacing.sm) - .padding(.vertical, MoriTokens.Spacing.sm) + .padding(.horizontal, MoriTokens.Spacing.xs) + .padding(.vertical, MoriTokens.Spacing.xs) .background( RoundedRectangle(cornerRadius: MoriTokens.Radius.medium) .fill( isSelectedProject - ? Color.primary.opacity(0.065) - : Color.primary.opacity(MoriTokens.Opacity.quiet) + ? Color.primary.opacity(0.04) + : Color.clear ) ) .overlay { RoundedRectangle(cornerRadius: MoriTokens.Radius.medium) .strokeBorder( isSelectedProject - ? MoriTokens.Color.active.opacity(0.35) - : Color.primary.opacity(MoriTokens.Opacity.subtle), + ? MoriTokens.Color.active.opacity(0.22) + : Color.primary.opacity(0.04), lineWidth: 1 ) } diff --git a/Sources/Mori/App/RootSplitViewController.swift b/Sources/Mori/App/RootSplitViewController.swift index ea9cc8e1..c3990c1e 100644 --- a/Sources/Mori/App/RootSplitViewController.swift +++ b/Sources/Mori/App/RootSplitViewController.swift @@ -13,10 +13,10 @@ final class RootSplitViewController: NSViewController { private(set) var companionController: NSViewController private static let sidebarWidthKey = "MoriSidebarWidth" - private static let sidebarCollapsedWidth: CGFloat = 52 + private static let sidebarCollapsedWidth: CGFloat = 56 private static let companionWidthKey = "MoriCompanionToolPaneWidth" - private static let sidebarMinWidth: CGFloat = 180 - private static let sidebarMaxWidth: CGFloat = 400 + private static let sidebarMinWidth: CGFloat = 220 + private static let sidebarMaxWidth: CGFloat = 260 private static let companionMinWidth: CGFloat = 320 private static let dividerHitWidth: CGFloat = 8 @@ -26,7 +26,7 @@ final class RootSplitViewController: NSViewController { private let companionDividerView = NSView() private let companionContainer = NSView() - private var sidebarWidth: CGFloat = 280 + private var sidebarWidth: CGFloat = 236 private var companionWidth: CGFloat = CompanionToolPaneState.defaultWidth private var dragTarget: DividerDragTarget? private var collapsed = false